2026-05-16 22:56:49 +00:00
|
|
|
//! `new project <name>` chat command — Phase 2a stack-overlay framework.
|
2026-05-16 22:34:24 +00:00
|
|
|
//!
|
2026-05-16 22:56:49 +00:00
|
|
|
//! Provisions a project container and registers it with the gateway.
|
2026-05-16 22:34:24 +00:00
|
|
|
//! The command is gateway-only: `new project <name> [--stack <stack>]`.
|
2026-05-16 22:56:49 +00:00
|
|
|
//!
|
|
|
|
|
//! Without `--stack`, the orchestrator inspects the (just-cloned or
|
|
|
|
|
//! just-init'd) source tree for stack markers found in
|
|
|
|
|
//! `docker/stacks/<name>/markers` files and auto-selects one, warning in
|
|
|
|
|
//! chat if multiple stacks matched. With `--stack`, the named stack is used
|
|
|
|
|
//! unconditionally.
|
|
|
|
|
//!
|
|
|
|
|
//! Stack images follow the naming convention `huskies-project-<stack>`.
|
|
|
|
|
//! The base image (no language tooling) is `huskies-project-base`.
|
|
|
|
|
//!
|
|
|
|
|
//! Adding a new stack requires only:
|
|
|
|
|
//! 1. `docker/stacks/<name>/Dockerfile.fragment` — overlay instructions
|
|
|
|
|
//! 2. `docker/stacks/<name>/markers` — detection marker filenames
|
|
|
|
|
//! No changes to this orchestration module are needed.
|
2026-05-16 22:34:24 +00:00
|
|
|
|
|
|
|
|
use std::collections::BTreeMap;
|
|
|
|
|
use std::path::Path;
|
|
|
|
|
use std::sync::Arc;
|
|
|
|
|
use tokio::sync::RwLock;
|
|
|
|
|
|
|
|
|
|
use crate::service::gateway::config::ProjectEntry;
|
|
|
|
|
|
2026-05-16 22:56:49 +00:00
|
|
|
/// Parsed result of a `new project <name> [--stack <stack>]` chat command.
|
|
|
|
|
pub struct NewProjectCommand {
|
|
|
|
|
/// Project name (alphanumeric, hyphens, underscores).
|
|
|
|
|
pub name: String,
|
|
|
|
|
/// Explicitly requested stack, or `None` for auto-detection.
|
|
|
|
|
pub stack: Option<String>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Parse a `new project <name> [--stack <stack>]` command from a chat message.
|
2026-05-16 22:34:24 +00:00
|
|
|
///
|
2026-05-16 22:56:49 +00:00
|
|
|
/// Returns `Some(NewProjectCommand)` when the stripped message starts with
|
|
|
|
|
/// "new project" (case-insensitive). An empty name (bare "new project" with
|
|
|
|
|
/// no arg) is returned as `Some(name="")` so the handler can emit a usage
|
|
|
|
|
/// error. Returns `None` for any other message.
|
2026-05-16 22:34:24 +00:00
|
|
|
pub fn extract_new_project_command(
|
|
|
|
|
message: &str,
|
|
|
|
|
bot_name: &str,
|
|
|
|
|
bot_user_id: &str,
|
2026-05-16 22:56:49 +00:00
|
|
|
) -> Option<NewProjectCommand> {
|
2026-05-16 22:34:24 +00:00
|
|
|
let mention_stripped = crate::chat::util::strip_bot_mention(message, bot_name, bot_user_id);
|
|
|
|
|
// Strip leading punctuation (e.g. colon after "@timmy: new project …")
|
|
|
|
|
let trimmed = mention_stripped
|
|
|
|
|
.trim()
|
|
|
|
|
.trim_start_matches(|c: char| !c.is_alphanumeric());
|
|
|
|
|
|
|
|
|
|
let mut words = trimmed.split_whitespace();
|
|
|
|
|
let first = words.next()?;
|
|
|
|
|
if !first.eq_ignore_ascii_case("new") {
|
|
|
|
|
return None;
|
|
|
|
|
}
|
|
|
|
|
let second = words.next()?;
|
|
|
|
|
if !second.eq_ignore_ascii_case("project") {
|
|
|
|
|
return None;
|
|
|
|
|
}
|
|
|
|
|
let name = words.next().unwrap_or("").to_string();
|
2026-05-16 22:56:49 +00:00
|
|
|
|
|
|
|
|
// Scan remaining tokens for `--stack <value>`.
|
|
|
|
|
let remaining: Vec<&str> = words.collect();
|
|
|
|
|
let stack = parse_stack_flag(&remaining);
|
|
|
|
|
|
|
|
|
|
Some(NewProjectCommand { name, stack })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Extract the value of `--stack <value>` from a token slice.
|
|
|
|
|
fn parse_stack_flag(tokens: &[&str]) -> Option<String> {
|
|
|
|
|
let mut iter = tokens.iter().peekable();
|
|
|
|
|
while let Some(tok) = iter.next() {
|
|
|
|
|
if *tok == "--stack"
|
|
|
|
|
&& let Some(val) = iter.next()
|
|
|
|
|
{
|
|
|
|
|
return Some(val.to_string());
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
None
|
2026-05-16 22:34:24 +00:00
|
|
|
}
|
|
|
|
|
|
2026-05-16 22:56:49 +00:00
|
|
|
/// Scan `stacks_dir` for per-stack `markers` files and detect which stacks
|
|
|
|
|
/// match the given project directory.
|
|
|
|
|
///
|
|
|
|
|
/// Returns `(selected_stack, warnings)` where `selected_stack` is the
|
|
|
|
|
/// auto-detected stack name (or `None` if no markers matched) and `warnings`
|
|
|
|
|
/// carries a human-readable message when multiple stacks matched.
|
|
|
|
|
///
|
|
|
|
|
/// Each `stacks_dir/<name>/markers` file lists one filename per line
|
|
|
|
|
/// (relative to the project root). Lines starting with `#` and blank lines
|
|
|
|
|
/// are ignored. If any listed file exists in `project_path`, that stack is
|
|
|
|
|
/// considered a match.
|
|
|
|
|
pub fn detect_stack(project_path: &Path, stacks_dir: &Path) -> (Option<String>, Vec<String>) {
|
|
|
|
|
let entries = match std::fs::read_dir(stacks_dir) {
|
|
|
|
|
Ok(e) => e,
|
|
|
|
|
Err(_) => return (None, vec![]),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let mut matched: Vec<String> = Vec::new();
|
|
|
|
|
|
|
|
|
|
let mut stack_dirs: Vec<_> = entries
|
|
|
|
|
.filter_map(|e| e.ok())
|
|
|
|
|
.filter(|e| e.path().is_dir())
|
|
|
|
|
.collect();
|
|
|
|
|
// Deterministic order so multi-match selection is stable.
|
|
|
|
|
stack_dirs.sort_by_key(|e| e.file_name());
|
|
|
|
|
|
|
|
|
|
for entry in stack_dirs {
|
|
|
|
|
let stack_name = entry.file_name().to_string_lossy().into_owned();
|
|
|
|
|
let markers_path = entry.path().join("markers");
|
|
|
|
|
let Ok(content) = std::fs::read_to_string(&markers_path) else {
|
|
|
|
|
continue;
|
|
|
|
|
};
|
|
|
|
|
let hit = content.lines().any(|line| {
|
|
|
|
|
let trimmed = line.trim();
|
|
|
|
|
if trimmed.is_empty() || trimmed.starts_with('#') {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
project_path.join(trimmed).exists()
|
|
|
|
|
});
|
|
|
|
|
if hit {
|
|
|
|
|
matched.push(stack_name);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
match matched.len() {
|
|
|
|
|
0 => (None, vec![]),
|
|
|
|
|
1 => (Some(matched.remove(0)), vec![]),
|
|
|
|
|
_ => {
|
|
|
|
|
let names = matched.join(", ");
|
|
|
|
|
let chosen = matched.remove(0);
|
|
|
|
|
let warning = format!(
|
|
|
|
|
"Multiple stacks detected ({names}); using **{chosen}**. \
|
|
|
|
|
Pass `--stack <name>` to override."
|
|
|
|
|
);
|
|
|
|
|
(Some(chosen), vec![warning])
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Return the Docker image name for the given stack.
|
|
|
|
|
///
|
|
|
|
|
/// Stack images follow the convention `huskies-project-<stack>`.
|
|
|
|
|
/// When no stack is specified, the base image `huskies-project-base` is used.
|
|
|
|
|
pub fn image_for_stack(stack: Option<&str>) -> String {
|
|
|
|
|
match stack {
|
|
|
|
|
Some(s) => format!("huskies-project-{s}"),
|
|
|
|
|
None => "huskies-project-base".to_string(),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Bootstrap a new project from the `new project <name> [--stack <stack>]` command.
|
2026-05-16 22:34:24 +00:00
|
|
|
///
|
|
|
|
|
/// Creates `~/huskies/<name>/`, scaffolds `.huskies/`, runs `git init`,
|
2026-05-16 22:56:49 +00:00
|
|
|
/// auto-detects or honours the requested stack, launches the appropriate
|
|
|
|
|
/// Docker container, and registers the project in both the gateway's
|
|
|
|
|
/// in-memory store and the CRDT.
|
2026-05-16 22:34:24 +00:00
|
|
|
///
|
|
|
|
|
/// On any failure after the host directory is created, the directory is removed
|
|
|
|
|
/// and the error message includes "Partial state removed at `<path>`".
|
|
|
|
|
pub async fn handle_new_project(
|
|
|
|
|
name: &str,
|
2026-05-16 22:56:49 +00:00
|
|
|
stack: Option<&str>,
|
2026-05-16 22:34:24 +00:00
|
|
|
projects_store: &Arc<RwLock<BTreeMap<String, ProjectEntry>>>,
|
|
|
|
|
config_dir: &Path,
|
|
|
|
|
) -> String {
|
|
|
|
|
let name = name.trim();
|
|
|
|
|
|
|
|
|
|
if name.is_empty() {
|
|
|
|
|
return "Usage: `new project <name>` — e.g. `new project myapp`".to_string();
|
|
|
|
|
}
|
|
|
|
|
if !name
|
|
|
|
|
.chars()
|
|
|
|
|
.all(|c| c.is_alphanumeric() || c == '-' || c == '_')
|
|
|
|
|
{
|
|
|
|
|
return format!(
|
|
|
|
|
"Invalid project name `{name}`. \
|
|
|
|
|
Use letters, digits, hyphens, or underscores only."
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Name conflict — check both the in-memory store and the CRDT.
|
|
|
|
|
{
|
|
|
|
|
let projects = projects_store.read().await;
|
|
|
|
|
if projects.contains_key(name) {
|
|
|
|
|
return format!(
|
|
|
|
|
"Project `{name}` is already registered. \
|
|
|
|
|
Use `switch {name}` to activate it."
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Default host path: ~/huskies/<name>/
|
|
|
|
|
let home = std::env::var("HOME").unwrap_or_else(|_| "/home/huskies".to_string());
|
|
|
|
|
let host_path = std::path::PathBuf::from(home).join("huskies").join(name);
|
|
|
|
|
|
|
|
|
|
if host_path.exists() {
|
|
|
|
|
return format!(
|
|
|
|
|
"Path `{}` already exists. \
|
|
|
|
|
Choose a different project name or remove the directory first.",
|
|
|
|
|
host_path.display()
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Create host directory ────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
if let Err(e) = crate::service::gateway::io::ensure_directory(&host_path) {
|
|
|
|
|
return format!("Failed to create `{}`: {e}", host_path.display());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Scaffold .huskies/ ───────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
if let Err(e) = crate::service::gateway::io::scaffold_project(&host_path) {
|
|
|
|
|
let _ = tokio::fs::remove_dir_all(&host_path).await;
|
|
|
|
|
return format!(
|
|
|
|
|
"Scaffold failed: {e}\n\nPartial state removed at `{}`.",
|
|
|
|
|
host_path.display()
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
crate::service::gateway::io::init_wizard_state(&host_path);
|
|
|
|
|
|
|
|
|
|
// ── git init ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
match tokio::process::Command::new("git")
|
|
|
|
|
.arg("init")
|
|
|
|
|
.arg(&host_path)
|
|
|
|
|
.output()
|
|
|
|
|
.await
|
|
|
|
|
{
|
|
|
|
|
Err(e) => {
|
|
|
|
|
let _ = tokio::fs::remove_dir_all(&host_path).await;
|
|
|
|
|
return format!(
|
|
|
|
|
"git init failed: {e}\n\nPartial state removed at `{}`.",
|
|
|
|
|
host_path.display()
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
Ok(out) if !out.status.success() => {
|
|
|
|
|
let stderr = String::from_utf8_lossy(&out.stderr);
|
|
|
|
|
let _ = tokio::fs::remove_dir_all(&host_path).await;
|
|
|
|
|
return format!(
|
|
|
|
|
"git init failed: {}\n\nPartial state removed at `{}`.",
|
|
|
|
|
stderr.trim(),
|
|
|
|
|
host_path.display()
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
Ok(_) => {}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-16 22:56:49 +00:00
|
|
|
// ── Detect or validate stack ─────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
let stacks_dir = config_dir.join("docker").join("stacks");
|
|
|
|
|
let (resolved_stack, detect_warnings) = match stack {
|
|
|
|
|
Some(s) => (Some(s.to_string()), vec![]),
|
|
|
|
|
None => detect_stack(&host_path, &stacks_dir),
|
|
|
|
|
};
|
|
|
|
|
let image = image_for_stack(resolved_stack.as_deref());
|
|
|
|
|
|
2026-05-16 22:34:24 +00:00
|
|
|
// ── Allocate port and launch container ───────────────────────────────────
|
|
|
|
|
|
|
|
|
|
let port = find_free_port(3100);
|
|
|
|
|
let container_url = format!("http://127.0.0.1:{port}");
|
|
|
|
|
let container_name = format!("huskies-{name}");
|
|
|
|
|
|
|
|
|
|
let docker_result = tokio::process::Command::new("docker")
|
|
|
|
|
.args([
|
|
|
|
|
"run",
|
|
|
|
|
"-d",
|
|
|
|
|
"--name",
|
|
|
|
|
&container_name,
|
|
|
|
|
"-p",
|
|
|
|
|
&format!("127.0.0.1:{port}:3001"),
|
|
|
|
|
"-v",
|
|
|
|
|
&format!("{}:/workspace", host_path.display()),
|
|
|
|
|
"--restart",
|
|
|
|
|
"unless-stopped",
|
2026-05-16 22:56:49 +00:00
|
|
|
&image,
|
2026-05-16 22:34:24 +00:00
|
|
|
"huskies",
|
|
|
|
|
"/workspace",
|
|
|
|
|
])
|
|
|
|
|
.output()
|
|
|
|
|
.await;
|
|
|
|
|
|
|
|
|
|
match docker_result {
|
|
|
|
|
Ok(out) if out.status.success() => {
|
|
|
|
|
// Register in the CRDT (survives restarts).
|
|
|
|
|
crate::crdt_state::write_gateway_project(name, &container_url);
|
|
|
|
|
|
|
|
|
|
// Update the in-memory projects store and persist to projects.toml.
|
|
|
|
|
{
|
|
|
|
|
let mut projects = projects_store.write().await;
|
|
|
|
|
projects.insert(name.to_string(), ProjectEntry::with_url(&container_url));
|
|
|
|
|
crate::service::gateway::io::save_config(&projects, config_dir).await;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-16 22:56:49 +00:00
|
|
|
crate::slog!(
|
|
|
|
|
"[new-project] Created project '{name}' at {container_url} (image={image})"
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let stack_note = match resolved_stack.as_deref() {
|
|
|
|
|
Some(s) => format!("- Stack: **{s}** (`{image}`)\n"),
|
|
|
|
|
None => String::new(),
|
|
|
|
|
};
|
|
|
|
|
let warning_block = if detect_warnings.is_empty() {
|
|
|
|
|
String::new()
|
|
|
|
|
} else {
|
|
|
|
|
format!("\n> {}\n", detect_warnings.join("\n> "))
|
|
|
|
|
};
|
2026-05-16 22:34:24 +00:00
|
|
|
|
|
|
|
|
format!(
|
2026-05-16 22:56:49 +00:00
|
|
|
"{warning_block}Project **{name}** is ready.\n\
|
2026-05-16 22:34:24 +00:00
|
|
|
- Host path: `{host}`\n\
|
|
|
|
|
- Container: `{container_name}` → `{container_url}`\n\
|
2026-05-16 22:56:49 +00:00
|
|
|
{stack_note}\
|
2026-05-16 22:34:24 +00:00
|
|
|
\n\
|
2026-05-16 22:56:49 +00:00
|
|
|
Use `switch {name}` then `status` to view the pipeline.",
|
2026-05-16 22:34:24 +00:00
|
|
|
host = host_path.display()
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
Ok(out) => {
|
|
|
|
|
let stderr = String::from_utf8_lossy(&out.stderr);
|
|
|
|
|
let _ = tokio::fs::remove_dir_all(&host_path).await;
|
|
|
|
|
format!(
|
|
|
|
|
"Docker container launch failed: {}\n\nPartial state removed at `{}`.",
|
|
|
|
|
stderr.trim(),
|
|
|
|
|
host_path.display()
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
Err(e) => {
|
|
|
|
|
let _ = tokio::fs::remove_dir_all(&host_path).await;
|
|
|
|
|
format!(
|
|
|
|
|
"Docker container launch failed: {e}\n\nPartial state removed at `{}`.",
|
|
|
|
|
host_path.display()
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Find a free TCP port by attempting to bind starting from `start`.
|
|
|
|
|
///
|
|
|
|
|
/// Scans up to 100 ports above `start` and returns the first available one.
|
|
|
|
|
/// Falls back to `start` if none are found (unlikely in practice).
|
|
|
|
|
fn find_free_port(start: u16) -> u16 {
|
|
|
|
|
for port in start..start + 100 {
|
|
|
|
|
if std::net::TcpListener::bind(("127.0.0.1", port)).is_ok() {
|
|
|
|
|
return port;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
start
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Tests
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::*;
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn extract_parses_name() {
|
2026-05-16 22:56:49 +00:00
|
|
|
let cmd =
|
|
|
|
|
extract_new_project_command("@timmy new project myapp", "Timmy", "@timmy:srv.local")
|
|
|
|
|
.unwrap();
|
|
|
|
|
assert_eq!(cmd.name, "myapp");
|
|
|
|
|
assert_eq!(cmd.stack, None);
|
2026-05-16 22:34:24 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn extract_case_insensitive() {
|
2026-05-16 22:56:49 +00:00
|
|
|
let cmd =
|
|
|
|
|
extract_new_project_command("@timmy NEW PROJECT myapp", "Timmy", "@timmy:srv.local")
|
|
|
|
|
.unwrap();
|
|
|
|
|
assert_eq!(cmd.name, "myapp");
|
2026-05-16 22:34:24 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn extract_bare_project_keyword_returns_empty_name() {
|
2026-05-16 22:56:49 +00:00
|
|
|
let cmd =
|
|
|
|
|
extract_new_project_command("@timmy new project", "Timmy", "@timmy:srv.local").unwrap();
|
|
|
|
|
assert_eq!(cmd.name, "");
|
2026-05-16 22:34:24 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
2026-05-16 22:56:49 +00:00
|
|
|
fn extract_parses_stack_flag() {
|
|
|
|
|
let cmd = extract_new_project_command(
|
|
|
|
|
"@timmy new project myapp --stack rust",
|
|
|
|
|
"Timmy",
|
|
|
|
|
"@timmy:srv.local",
|
|
|
|
|
)
|
|
|
|
|
.unwrap();
|
|
|
|
|
assert_eq!(cmd.name, "myapp");
|
|
|
|
|
assert_eq!(cmd.stack, Some("rust".to_string()));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn extract_stack_node() {
|
|
|
|
|
let cmd = extract_new_project_command(
|
|
|
|
|
"@timmy new project myapp --stack node",
|
|
|
|
|
"Timmy",
|
|
|
|
|
"@timmy:srv.local",
|
|
|
|
|
)
|
|
|
|
|
.unwrap();
|
|
|
|
|
assert_eq!(cmd.stack, Some("node".to_string()));
|
2026-05-16 22:34:24 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn extract_no_match_for_other_commands() {
|
2026-05-16 22:56:49 +00:00
|
|
|
assert!(
|
|
|
|
|
extract_new_project_command("@timmy status", "Timmy", "@timmy:srv.local").is_none()
|
2026-05-16 22:34:24 +00:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn extract_no_match_when_second_word_is_not_project() {
|
2026-05-16 22:56:49 +00:00
|
|
|
assert!(
|
|
|
|
|
extract_new_project_command("@timmy new myapp", "Timmy", "@timmy:srv.local").is_none()
|
2026-05-16 22:34:24 +00:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn extract_handles_extra_whitespace() {
|
2026-05-16 22:56:49 +00:00
|
|
|
let cmd = extract_new_project_command(
|
|
|
|
|
"@timmy new project myapp",
|
|
|
|
|
"Timmy",
|
|
|
|
|
"@timmy:srv.local",
|
|
|
|
|
)
|
|
|
|
|
.unwrap();
|
|
|
|
|
assert_eq!(cmd.name, "myapp");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn image_for_stack_rust() {
|
|
|
|
|
assert_eq!(image_for_stack(Some("rust")), "huskies-project-rust");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn image_for_stack_node() {
|
|
|
|
|
assert_eq!(image_for_stack(Some("node")), "huskies-project-node");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn image_for_stack_base() {
|
|
|
|
|
assert_eq!(image_for_stack(None), "huskies-project-base");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn detect_stack_rust_marker() {
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
// Create a fake project with Cargo.toml
|
|
|
|
|
std::fs::write(dir.path().join("Cargo.toml"), "[package]").unwrap();
|
|
|
|
|
|
|
|
|
|
// Create a fake stacks dir
|
|
|
|
|
let stacks = tempfile::tempdir().unwrap();
|
|
|
|
|
std::fs::create_dir_all(stacks.path().join("rust")).unwrap();
|
|
|
|
|
std::fs::write(
|
|
|
|
|
stacks.path().join("rust/markers"),
|
|
|
|
|
"# comment\nCargo.toml\n",
|
|
|
|
|
)
|
|
|
|
|
.unwrap();
|
|
|
|
|
std::fs::create_dir_all(stacks.path().join("node")).unwrap();
|
|
|
|
|
std::fs::write(
|
|
|
|
|
stacks.path().join("node/markers"),
|
|
|
|
|
"package.json\ntsconfig.json\n",
|
|
|
|
|
)
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
|
|
|
|
let (stack, warnings) = detect_stack(dir.path(), stacks.path());
|
|
|
|
|
assert_eq!(stack, Some("rust".to_string()));
|
|
|
|
|
assert!(warnings.is_empty());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn detect_stack_node_tsconfig() {
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
std::fs::write(dir.path().join("tsconfig.json"), "{}").unwrap();
|
|
|
|
|
|
|
|
|
|
let stacks = tempfile::tempdir().unwrap();
|
|
|
|
|
std::fs::create_dir_all(stacks.path().join("node")).unwrap();
|
|
|
|
|
std::fs::write(
|
|
|
|
|
stacks.path().join("node/markers"),
|
|
|
|
|
"package.json\ntsconfig.json\n",
|
|
|
|
|
)
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
|
|
|
|
let (stack, warnings) = detect_stack(dir.path(), stacks.path());
|
|
|
|
|
assert_eq!(stack, Some("node".to_string()));
|
|
|
|
|
assert!(warnings.is_empty());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn detect_stack_no_markers_returns_none() {
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
let stacks = tempfile::tempdir().unwrap();
|
|
|
|
|
std::fs::create_dir_all(stacks.path().join("rust")).unwrap();
|
|
|
|
|
std::fs::write(stacks.path().join("rust/markers"), "Cargo.toml\n").unwrap();
|
|
|
|
|
|
|
|
|
|
let (stack, warnings) = detect_stack(dir.path(), stacks.path());
|
|
|
|
|
assert_eq!(stack, None);
|
|
|
|
|
assert!(warnings.is_empty());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn detect_stack_multiple_warn() {
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
// Both Cargo.toml and package.json exist.
|
|
|
|
|
std::fs::write(dir.path().join("Cargo.toml"), "").unwrap();
|
|
|
|
|
std::fs::write(dir.path().join("package.json"), "{}").unwrap();
|
|
|
|
|
|
|
|
|
|
let stacks = tempfile::tempdir().unwrap();
|
|
|
|
|
std::fs::create_dir_all(stacks.path().join("node")).unwrap();
|
|
|
|
|
std::fs::write(stacks.path().join("node/markers"), "package.json\n").unwrap();
|
|
|
|
|
std::fs::create_dir_all(stacks.path().join("rust")).unwrap();
|
|
|
|
|
std::fs::write(stacks.path().join("rust/markers"), "Cargo.toml\n").unwrap();
|
|
|
|
|
|
|
|
|
|
let (stack, warnings) = detect_stack(dir.path(), stacks.path());
|
|
|
|
|
// Alphabetically first: "node" < "rust"
|
|
|
|
|
assert_eq!(stack, Some("node".to_string()));
|
|
|
|
|
assert_eq!(warnings.len(), 1);
|
|
|
|
|
assert!(warnings[0].contains("Multiple stacks"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn detect_stack_missing_stacks_dir_returns_none() {
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
let (stack, warnings) = detect_stack(dir.path(), Path::new("/nonexistent/stacks"));
|
|
|
|
|
assert_eq!(stack, None);
|
|
|
|
|
assert!(warnings.is_empty());
|
2026-05-16 22:34:24 +00:00
|
|
|
}
|
|
|
|
|
}
|