huskies: merge 1107 story Chat bootstrap Phase 2a: stack-overlay framework + Rust and Node stack overlays
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
# huskies-project-base — minimal base for all project containers.
|
||||
#
|
||||
# This image provides git, the huskies server binary, and a non-root user.
|
||||
# It carries no language tooling. Per-stack overlays (docker/stacks/<name>/
|
||||
# Dockerfile.fragment) layer their toolchains on top of this base.
|
||||
#
|
||||
# Prerequisites: build the main `huskies` image first so its binary is
|
||||
# available as a build source.
|
||||
#
|
||||
# docker build -t huskies -f docker/Dockerfile .
|
||||
# docker build -t huskies-project-base -f docker/Dockerfile.base .
|
||||
#
|
||||
# To build a stack image (e.g. rust):
|
||||
# (echo "FROM huskies-project-base"; \
|
||||
# cat docker/stacks/rust/Dockerfile.fragment) | \
|
||||
# docker build -t huskies-project-rust -
|
||||
|
||||
FROM huskies AS huskies-src
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
git \
|
||||
curl \
|
||||
ca-certificates \
|
||||
libssl3 \
|
||||
procps \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy the huskies binary and entrypoint from the main image.
|
||||
COPY --from=huskies-src /usr/local/bin/huskies /usr/local/bin/huskies
|
||||
COPY --from=huskies-src /usr/local/bin/entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||
|
||||
# Non-root user — Claude Code refuses --dangerously-skip-permissions as root.
|
||||
RUN groupadd -r huskies \
|
||||
&& useradd -r -g huskies -m -d /home/huskies huskies \
|
||||
&& mkdir -p /home/huskies/.claude \
|
||||
&& chown -R huskies:huskies /home/huskies \
|
||||
&& mkdir -p /workspace \
|
||||
&& chown huskies:huskies /workspace \
|
||||
&& git config --global init.defaultBranch master
|
||||
|
||||
USER huskies
|
||||
WORKDIR /workspace
|
||||
|
||||
EXPOSE 3001
|
||||
|
||||
ENTRYPOINT ["entrypoint.sh"]
|
||||
CMD ["huskies", "/workspace"]
|
||||
@@ -0,0 +1,26 @@
|
||||
# Node stack overlay fragment.
|
||||
#
|
||||
# Layer this on top of huskies-project-base to produce a project container
|
||||
# with Node.js 22, TypeScript (tsc), and typescript-language-server.
|
||||
#
|
||||
# Build the combined image:
|
||||
# (echo "FROM huskies-project-base"; \
|
||||
# cat docker/stacks/node/Dockerfile.fragment) | \
|
||||
# docker build -t huskies-project-node -
|
||||
#
|
||||
# Adding a new stack: create docker/stacks/<name>/Dockerfile.fragment and
|
||||
# docker/stacks/<name>/markers — no changes to orchestration code required.
|
||||
|
||||
USER root
|
||||
|
||||
# Node.js 22.x (LTS).
|
||||
RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
|
||||
&& apt-get install -y --no-install-recommends nodejs \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# TypeScript compiler and language server for LSP-aware agents.
|
||||
# tsc: TypeScript compiler (tsc --version)
|
||||
# typescript-language-server: LSP server used by editors/agents
|
||||
RUN npm install -g typescript typescript-language-server
|
||||
|
||||
USER huskies
|
||||
@@ -0,0 +1,7 @@
|
||||
# Stack detection markers for the node stack.
|
||||
# Each non-blank, non-comment line names a file relative to the project root.
|
||||
# If any listed file exists in the project directory, this stack is matched.
|
||||
# tsconfig.json is listed explicitly so TypeScript-only projects are detected
|
||||
# even without a package.json at the repo root.
|
||||
package.json
|
||||
tsconfig.json
|
||||
@@ -0,0 +1,37 @@
|
||||
# Rust stack overlay fragment.
|
||||
#
|
||||
# Layer this on top of huskies-project-base to produce a project container
|
||||
# with a full Rust toolchain, rust-analyzer, and cargo-nextest.
|
||||
#
|
||||
# Build the combined image:
|
||||
# (echo "FROM huskies-project-base"; \
|
||||
# cat docker/stacks/rust/Dockerfile.fragment) | \
|
||||
# docker build -t huskies-project-rust -
|
||||
#
|
||||
# Adding a new stack: create docker/stacks/<name>/Dockerfile.fragment and
|
||||
# docker/stacks/<name>/markers — no changes to orchestration code required.
|
||||
|
||||
USER root
|
||||
|
||||
# Build tools required by rustup and many Rust crates.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
pkg-config \
|
||||
libssl-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ENV RUSTUP_HOME="/home/huskies/.rustup" \
|
||||
CARGO_HOME="/home/huskies/.cargo"
|
||||
|
||||
# Install stable Rust + rust-analyzer component as the huskies user.
|
||||
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
|
||||
| su huskies -c "sh -s -- -y --no-modify-path --default-toolchain stable" \
|
||||
&& /home/huskies/.cargo/bin/rustup component add rust-analyzer \
|
||||
&& chown -R huskies:huskies /home/huskies/.rustup /home/huskies/.cargo
|
||||
|
||||
# cargo-nextest: fast Rust test runner used by huskies quality gates.
|
||||
RUN curl -LsSf https://get.nexte.st/latest/linux | tar zxf - -C /usr/local/bin
|
||||
|
||||
ENV PATH="/home/huskies/.cargo/bin:${PATH}"
|
||||
|
||||
USER huskies
|
||||
@@ -0,0 +1,4 @@
|
||||
# Stack detection markers for the rust stack.
|
||||
# Each non-blank, non-comment line names a file relative to the project root.
|
||||
# If any listed file exists in the project directory, this stack is matched.
|
||||
Cargo.toml
|
||||
@@ -260,19 +260,24 @@ pub(in crate::chat::transport::matrix::bot) async fn on_room_message(
|
||||
// Gateway-local commands and freeform text fall through to normal handling below.
|
||||
}
|
||||
|
||||
// In gateway mode, handle the "new project <name>" command to bootstrap a
|
||||
// bare project container and register it with the gateway.
|
||||
// In gateway mode, handle the "new project <name> [--stack <stack>]" command
|
||||
// to bootstrap a project container and register it with the gateway.
|
||||
if ctx.is_gateway()
|
||||
&& let Some(project_name) = super::super::super::new_project::extract_new_project_command(
|
||||
&& let Some(cmd) = super::super::super::new_project::extract_new_project_command(
|
||||
&user_message,
|
||||
&ctx.services.bot_name,
|
||||
ctx.matrix_user_id.as_str(),
|
||||
)
|
||||
{
|
||||
slog!("[matrix-bot] Handling new project command from {sender}: name={project_name:?}");
|
||||
slog!(
|
||||
"[matrix-bot] Handling new project command from {sender}: name={:?} stack={:?}",
|
||||
cmd.name,
|
||||
cmd.stack
|
||||
);
|
||||
let response = if let Some(ref store) = ctx.gateway_projects_store {
|
||||
super::super::super::new_project::handle_new_project(
|
||||
&project_name,
|
||||
&cmd.name,
|
||||
cmd.stack.as_deref(),
|
||||
store,
|
||||
&ctx.services.project_root,
|
||||
)
|
||||
|
||||
@@ -1,8 +1,21 @@
|
||||
//! `new project <name>` chat command — Phase 1 bootstrap.
|
||||
//! `new project <name>` chat command — Phase 2a stack-overlay framework.
|
||||
//!
|
||||
//! Provisions a bare project container and registers it with the gateway.
|
||||
//! Provisions a project container and registers it with the gateway.
|
||||
//! The command is gateway-only: `new project <name> [--stack <stack>]`.
|
||||
//! Phase 1 ignores `--stack`; stack-aware images arrive in Phase 2.
|
||||
//!
|
||||
//! 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.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::Path;
|
||||
@@ -11,17 +24,25 @@ use tokio::sync::RwLock;
|
||||
|
||||
use crate::service::gateway::config::ProjectEntry;
|
||||
|
||||
/// Parse a `new project <name>` command from a chat message.
|
||||
/// 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.
|
||||
///
|
||||
/// Returns `Some(name)` when the stripped message starts with "new project"
|
||||
/// (case-insensitive). An empty name (bare "new project" with no arg) is
|
||||
/// returned as `Some("")` so the handler can emit a usage error. Returns `None`
|
||||
/// for any other message.
|
||||
/// 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.
|
||||
pub fn extract_new_project_command(
|
||||
message: &str,
|
||||
bot_name: &str,
|
||||
bot_user_id: &str,
|
||||
) -> Option<String> {
|
||||
) -> Option<NewProjectCommand> {
|
||||
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
|
||||
@@ -37,21 +58,110 @@ pub fn extract_new_project_command(
|
||||
if !second.eq_ignore_ascii_case("project") {
|
||||
return None;
|
||||
}
|
||||
// Third word is the project name; later words (flags) are ignored in Phase 1.
|
||||
let name = words.next().unwrap_or("").to_string();
|
||||
Some(name)
|
||||
|
||||
// Scan remaining tokens for `--stack <value>`.
|
||||
let remaining: Vec<&str> = words.collect();
|
||||
let stack = parse_stack_flag(&remaining);
|
||||
|
||||
Some(NewProjectCommand { name, stack })
|
||||
}
|
||||
|
||||
/// Bootstrap a new project from the `new project <name>` command.
|
||||
/// 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
|
||||
}
|
||||
|
||||
/// 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.
|
||||
///
|
||||
/// Creates `~/huskies/<name>/`, scaffolds `.huskies/`, runs `git init`,
|
||||
/// launches a Docker container (`huskies-project-base`), and registers the
|
||||
/// project in both the gateway's in-memory store and the CRDT.
|
||||
/// 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.
|
||||
///
|
||||
/// 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,
|
||||
stack: Option<&str>,
|
||||
projects_store: &Arc<RwLock<BTreeMap<String, ProjectEntry>>>,
|
||||
config_dir: &Path,
|
||||
) -> String {
|
||||
@@ -137,6 +247,15 @@ pub async fn handle_new_project(
|
||||
Ok(_) => {}
|
||||
}
|
||||
|
||||
// ── 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());
|
||||
|
||||
// ── Allocate port and launch container ───────────────────────────────────
|
||||
|
||||
let port = find_free_port(3100);
|
||||
@@ -155,7 +274,7 @@ pub async fn handle_new_project(
|
||||
&format!("{}:/workspace", host_path.display()),
|
||||
"--restart",
|
||||
"unless-stopped",
|
||||
"huskies-project-base",
|
||||
&image,
|
||||
"huskies",
|
||||
"/workspace",
|
||||
])
|
||||
@@ -174,16 +293,27 @@ pub async fn handle_new_project(
|
||||
crate::service::gateway::io::save_config(&projects, config_dir).await;
|
||||
}
|
||||
|
||||
crate::slog!("[new-project] Created project '{name}' at {container_url}");
|
||||
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> "))
|
||||
};
|
||||
|
||||
format!(
|
||||
"Project **{name}** is ready.\n\
|
||||
"{warning_block}Project **{name}** is ready.\n\
|
||||
- Host path: `{host}`\n\
|
||||
- Container: `{container_name}` → `{container_url}`\n\
|
||||
{stack_note}\
|
||||
\n\
|
||||
Use `switch {name}` then `status` to view the pipeline.\n\
|
||||
*Stack tooling (Rust, Node, etc.) comes in Phase 2 — \
|
||||
pass `--stack <name>` now for forward-compat.*",
|
||||
Use `switch {name}` then `status` to view the pipeline.",
|
||||
host = host_path.display()
|
||||
)
|
||||
}
|
||||
@@ -229,65 +359,172 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn extract_parses_name() {
|
||||
assert_eq!(
|
||||
extract_new_project_command("@timmy new project myapp", "Timmy", "@timmy:srv.local"),
|
||||
Some("myapp".to_string())
|
||||
);
|
||||
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);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_case_insensitive() {
|
||||
assert_eq!(
|
||||
extract_new_project_command("@timmy NEW PROJECT myapp", "Timmy", "@timmy:srv.local"),
|
||||
Some("myapp".to_string())
|
||||
);
|
||||
let cmd =
|
||||
extract_new_project_command("@timmy NEW PROJECT myapp", "Timmy", "@timmy:srv.local")
|
||||
.unwrap();
|
||||
assert_eq!(cmd.name, "myapp");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_bare_project_keyword_returns_empty_name() {
|
||||
assert_eq!(
|
||||
extract_new_project_command("@timmy new project", "Timmy", "@timmy:srv.local"),
|
||||
Some("".to_string())
|
||||
);
|
||||
let cmd =
|
||||
extract_new_project_command("@timmy new project", "Timmy", "@timmy:srv.local").unwrap();
|
||||
assert_eq!(cmd.name, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_ignores_flags_after_name() {
|
||||
assert_eq!(
|
||||
extract_new_project_command(
|
||||
"@timmy new project myapp --stack rust",
|
||||
"Timmy",
|
||||
"@timmy:srv.local"
|
||||
),
|
||||
Some("myapp".to_string())
|
||||
);
|
||||
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()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_no_match_for_other_commands() {
|
||||
assert_eq!(
|
||||
extract_new_project_command("@timmy status", "Timmy", "@timmy:srv.local"),
|
||||
None
|
||||
assert!(
|
||||
extract_new_project_command("@timmy status", "Timmy", "@timmy:srv.local").is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_no_match_when_second_word_is_not_project() {
|
||||
assert_eq!(
|
||||
extract_new_project_command("@timmy new myapp", "Timmy", "@timmy:srv.local"),
|
||||
None
|
||||
assert!(
|
||||
extract_new_project_command("@timmy new myapp", "Timmy", "@timmy:srv.local").is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_handles_extra_whitespace() {
|
||||
assert_eq!(
|
||||
extract_new_project_command(
|
||||
"@timmy new project myapp",
|
||||
"Timmy",
|
||||
"@timmy:srv.local"
|
||||
),
|
||||
Some("myapp".to_string())
|
||||
);
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user