diff --git a/skills/corgea/SKILL.md b/skills/corgea/SKILL.md index f6dd402..0d6a5ae 100644 --- a/skills/corgea/SKILL.md +++ b/skills/corgea/SKILL.md @@ -66,6 +66,7 @@ corgea scan --sbom sbom.cdx.json # SBOM to a custom file corgea scan --include-image myapp:1.2.3 # Also scan a fully built container image corgea scan --include-image myapp:1.2.3 --include-image ghcr.io/acme/api:latest # Repeatable corgea scan --project-name my-service # Override project name +corgea scan --project-naming-mode sparse-postfixed # One project per sparse-checkout service (monorepo-jsoar-agent) corgea scan --skip-if-commit-scanned-recently # Reuse a recent scan of this commit instead of scanning again corgea scan --skip-if-commit-scanned-recently --scanned-within 4h # Window for "recently" (default 24h) corgea scan --skip-if-commit-scanned-recently --ignore-dirty-worktree # Reuse even if this tree or the prior scan is dirty @@ -89,6 +90,8 @@ A pattern that matches the whole repository (`**`, `*`, `/**`) is refused, by bo Include rules also take scan reuse off the table. A reusable scan ran before the rules existed, so its results cannot cover the files they force in — a run with any include rule (from the flag or the project) starts a real scan and prints `CORGEA_SCAN_SKIPPED=false`, as does a run whose rule lookup failed. +`--project-naming-mode sparse-postfixed` names the project for a monorepo service cloned with a sparse checkout: the default project name (the repository name) followed by the checked-out top-level folders, joined with dots — `monorepo-jsoar-agent`, or `monorepo-common.jsoar-agent` for two folders. A nested folder counts as its top-level folder (`services/payments` gives `monorepo-services`), root files listed in the sparse checkout (`package.json`) are left out, characters outside `A-Za-z0-9._-` become `_`, and the name is cut at 100 characters. It only applies at the repository root of a sparse checkout; anywhere else it warns and the default name is used. It cannot be combined with `--project-name`. `upload`, `list` and `wait` take the same flag, so pass it there too to reach the same project. + `--only-uncommitted` and `--target` are mutually exclusive. `--fail-on`, `--fail`, and `--block-on` are mutually exclusive. `--out-format`/`--out-file` and `--sbom` are honored regardless of the gate: the report and the SBOM are written before `--fail`/`--block-on` are evaluated, so a scan that exits 1 on a blocking rule still leaves the report file behind for the pipeline to ingest. @@ -118,6 +121,7 @@ By default `upload` prints the scan page URL so you can track the results. Pass ```bash corgea wait # Wait for latest scan corgea wait SCAN_ID # Wait for a specific scan +corgea wait --project-naming-mode sparse-postfixed # Latest scan of the sparse-checkout service's project ``` Waiting (`corgea scan`, `corgea wait`, `corgea upload --wait`) exits 1 if the diff --git a/src/main.rs b/src/main.rs index 3d8456b..ed2717c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,6 +9,7 @@ mod list; mod log; mod manifest; mod mcp; +mod project_naming; mod scan; mod setup_hooks; mod skill; @@ -29,9 +30,14 @@ mod targets; use clap::{CommandFactory, Parser, Subcommand}; use config::Config; +use project_naming::ProjectNamingMode; use scanners::fortify::parse as fortify_parse; use std::str::FromStr; +const PROJECT_NAMING_MODE_HELP: &str = "Derive the Corgea project name instead of passing --project-name. sparse-postfixed: in a sparse checkout at the repository root, the checked-out top-level folders are appended to the default project name (monorepo-jsoar-agent for a checkout of jsoar-agent from monorepo), so each service of a monorepo gets its own project. Anywhere else it warns and the default name is used."; + +const PROJECT_NAMING_MODE_QUERY_HELP: &str = "Query the project that a scan with the same --project-naming-mode names. sparse-postfixed: in a sparse checkout at the repository root, the default project name followed by the checked-out top-level folders (monorepo-jsoar-agent). Anywhere else it warns and the project is resolved as usual."; + #[derive(Parser, Debug)] #[command(author, version, about, long_about = None)] #[command(propagate_version = true)] @@ -78,6 +84,15 @@ enum Commands { )] project_name: Option, + #[arg( + long, + value_enum, + value_name = "MODE", + conflicts_with = "project_name", + help = PROJECT_NAMING_MODE_HELP + )] + project_naming_mode: Option, + #[arg( long, help = "Wait for the uploaded scan to complete and print the results. Without this flag, the command prints the scan page URL so you can track the results." @@ -178,6 +193,15 @@ enum Commands { )] project_name: Option, + #[arg( + long, + value_enum, + value_name = "MODE", + conflicts_with = "project_name", + help = PROJECT_NAMING_MODE_HELP + )] + project_naming_mode: Option, + #[arg( long, value_name = "FILE", @@ -224,6 +248,14 @@ enum Commands { help = "Query this exact Corgea project name directly (skips repo auto-resolution)." )] project_name: Option, + #[arg( + long, + value_enum, + value_name = "MODE", + conflicts_with_all = ["project_name", "repo"], + help = PROJECT_NAMING_MODE_QUERY_HELP + )] + project_naming_mode: Option, #[arg( long, help = "Resolve the project from this repo (org/repo slug or remote URL) instead of the git remote." @@ -277,6 +309,15 @@ enum Commands { )] project_name: Option, + #[arg( + long, + value_enum, + value_name = "MODE", + conflicts_with_all = ["project_name", "repo"], + help = PROJECT_NAMING_MODE_QUERY_HELP + )] + project_naming_mode: Option, + #[arg( long, help = "Resolve the project from this repo (org/repo slug or remote URL) instead of the git remote." @@ -689,9 +730,11 @@ fn main() { Some(Commands::Upload { report, project_name, + project_naming_mode, wait, }) => { verify_token_and_exit_when_fail(&corgea_config); + let project_name = project_naming::resolve(project_name.clone(), *project_naming_mode); let result = match report { Some(report) => { if report.ends_with(".fpr") { @@ -737,6 +780,7 @@ fn main() { exclude, include, project_name, + project_naming_mode, sbom, include_image, skip_if_commit_scanned_recently, @@ -915,6 +959,8 @@ fn main() { None }; + let project_name = project_naming::resolve(project_name.clone(), *project_naming_mode); + match scanner { Scanner::Snyk => scan::run_snyk(&corgea_config, project_name.clone()), Scanner::Semgrep => scan::run_semgrep(&corgea_config, project_name.clone()), @@ -945,6 +991,7 @@ fn main() { Some(Commands::Wait { scan_id, project_name, + project_naming_mode, repo, project_id, }) => { @@ -954,7 +1001,7 @@ fn main() { wait::WaitArgs { scan_id: scan_id.clone(), selector: utils::api::ProjectSelector { - name: project_name.clone(), + name: project_naming::resolve(project_name.clone(), *project_naming_mode), repo: repo.clone(), }, project_id: project_id.clone(), @@ -970,6 +1017,7 @@ fn main() { sca_issues, code_quality, project_name, + project_naming_mode, repo, }) => { verify_token_and_exit_when_fail(&corgea_config); @@ -999,7 +1047,7 @@ fn main() { page_size: *page_size, scan_id: scan_id.clone(), selector: utils::api::ProjectSelector { - name: project_name.clone(), + name: project_naming::resolve(project_name.clone(), *project_naming_mode), repo: repo.clone(), }, }, diff --git a/src/project_naming.rs b/src/project_naming.rs new file mode 100644 index 0000000..9b61b59 --- /dev/null +++ b/src/project_naming.rs @@ -0,0 +1,388 @@ +use crate::utils::generic; +use clap::ValueEnum; +use std::collections::{BTreeSet, HashSet}; +use std::path::Path; +use std::process::Command; + +/// The SEAL pipeline template cuts derived names at this length, so the CLI +/// does too: a longer name would not match the projects the template created. +const MAX_DERIVED_NAME_CHARS: usize = 100; + +/// Git's default path quoting, which CI checkouts run with. +const QUOTE_PATH: &str = "core.quotePath=true"; + +/// Ways to derive the Corgea project name, picked with `--project-naming-mode`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +pub enum ProjectNamingMode { + /// The default project name followed by the folders of a sparse checkout. + SparsePostfixed, +} + +/// The project name a command runs against: `--project-name` as given, else +/// the name `--project-naming-mode` derives. None leaves the command to its +/// regular resolution, as when neither flag is passed. +pub fn resolve(project_name: Option, mode: Option) -> Option { + project_name.or_else(|| mode.and_then(name_from_mode)) +} + +/// The name `mode` derives, or None when the mode does not apply to this +/// checkout. +fn name_from_mode(mode: ProjectNamingMode) -> Option { + match mode { + ProjectNamingMode::SparsePostfixed => resolve_sparse_postfixed_name(), + } +} + +/// `-`, e.g. `monorepo-jsoar-agent`, so +/// every service cloned from a monorepo with a sparse checkout lands in its own +/// project. +fn resolve_sparse_postfixed_name() -> Option { + let folders = match sparse_checkout_folders(Path::new(".")) { + Ok(folders) => folders, + Err(reason) => { + log::warn!( + "--project-naming-mode=sparse-postfixed: {reason}, so the default project name is used." + ); + return None; + } + }; + let base = generic::determine_project_name(None); + let name = postfixed_name(&base, &folders); + log::info!( + "Corgea project: {name} (default name '{base}' plus sparse checkout folders: {})", + folders.join(", ") + ); + Some(name) +} + +/// The top-level folders `dir`'s sparse checkout keeps, or why there are none. +/// Only a worktree root qualifies: the default name and git context are +/// resolved there, and nowhere else. +fn sparse_checkout_folders(dir: &Path) -> Result, String> { + if !dir.to_str().is_some_and(generic::is_at_repo_root) { + return Err("the current directory is not the root of a git repository".into()); + } + // `git sparse-checkout` writes this to `config.worktree`, so it is asked of + // git rather than read from `.git/config`. + let enabled = git_stdout( + dir, + &[ + "config", + "--bool", + "--default", + "false", + "core.sparseCheckout", + ], + ) + .ok_or("git could not report whether this is a sparse checkout")?; + if enabled.trim() != "true" { + return Err("this checkout is not a sparse checkout".into()); + } + // Pinned so a `core.quotePath=false` in the user's git config cannot turn + // a non-ASCII folder into a different project name than CI derives. + let mut patterns = + git_stdout(dir, &["-c", QUOTE_PATH, "sparse-checkout", "list"]).unwrap_or_default(); + // `sparse-checkout list` needs git 2.25; older git only has the file. + if patterns.trim().is_empty() { + patterns = git_stdout(dir, &["rev-parse", "--git-path", "info/sparse-checkout"]) + .and_then(|path| std::fs::read_to_string(dir.join(path.trim())).ok()) + .unwrap_or_default(); + } + // Git accepts a root file such as `package.json` in either mode, and a + // file is no service folder. + let root_files = root_files(dir); + let mut folders = top_level_folders(&patterns); + folders.retain(|folder| !root_files.contains(folder)); + if folders.is_empty() { + return Err( + "sparse checkout is on, but no folder could be derived from its patterns".into(), + ); + } + Ok(folders) +} + +/// The first path segment of every pattern that names one, de-duplicated in +/// byte order. Follows the SEAL pipeline template step for step so both derive +/// the same name for a checkout: comments, negations and root-only patterns +/// (`/*`, `*.md`) name no folder, and `services/payments` reduces to +/// `services`. +fn top_level_folders(patterns: &str) -> Vec { + let folders: BTreeSet = patterns + .lines() + .filter_map(|line| { + // Some pipelines write each pattern with a literal `\n` after it. + let line = line.strip_suffix("\\n").unwrap_or(line); + let line = line.trim_matches(|c: char| c.is_ascii_whitespace() || c == '\x0b'); + if line.is_empty() || line.starts_with('#') || line.starts_with('!') { + return None; + } + let path = line.strip_prefix('/').unwrap_or(line); + let folder = path.split('/').next().unwrap_or_default(); + (!folder.is_empty() && !folder.starts_with('*')).then(|| folder.to_string()) + }) + .collect(); + folders.into_iter().collect() +} + +/// Files at the top of `HEAD`'s tree, quoted as `sparse-checkout list` quotes +/// them. Empty when there is no commit to read. +fn root_files(dir: &Path) -> HashSet { + git_stdout(dir, &["-c", QUOTE_PATH, "ls-tree", "HEAD"]) + .unwrap_or_default() + .lines() + .filter_map(|line| { + let (meta, name) = line.split_once('\t')?; + (meta.split_whitespace().nth(1) == Some("blob")).then(|| name.to_string()) + }) + .collect() +} + +/// `base`, a dash, then the folders joined with dots. The folders get the +/// template's byte-wise character rule, so a non-ASCII byte becomes `_`. +fn postfixed_name(base: &str, folders: &[String]) -> String { + let suffix: String = folders + .join(".") + .bytes() + .map(|b| match b { + b',' => '.', + b'.' | b'_' | b'-' => b as char, + b if b.is_ascii_alphanumeric() => b as char, + _ => '_', + }) + .collect(); + format!("{base}-{suffix}") + .chars() + .take(MAX_DERIVED_NAME_CHARS) + .collect() +} + +/// Stdout of a successful `git` run in `dir`, or None when git is missing or +/// the command failed. +fn git_stdout(dir: &Path, args: &[&str]) -> Option { + let mut command = Command::new("git"); + for var in generic::GIT_LOCAL_ENV_VARS { + command.env_remove(var); + } + let output = command.args(args).current_dir(dir).output().ok()?; + output + .status + .success() + .then(|| String::from_utf8_lossy(&output.stdout).into_owned()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + fn git(root: &Path, args: &[&str]) { + let mut cmd = Command::new("git"); + for (name, _) in std::env::vars() { + if name.starts_with("GIT_") { + cmd.env_remove(name); + } + } + assert!( + cmd.args(args).current_dir(root).status().unwrap().success(), + "git {args:?} failed" + ); + } + + /// A committed repo holding one file in each of `dirs`, plus a root file. + fn monorepo(dirs: &[&str]) -> tempfile::TempDir { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + git(root, &["init", "-q"]); + for dir in dirs { + fs::create_dir_all(root.join(dir)).unwrap(); + fs::write(root.join(dir).join("f.txt"), "x").unwrap(); + } + fs::write(root.join("root.txt"), "x").unwrap(); + git(root, &["add", "-A"]); + git( + root, + &[ + "-c", + "user.name=t", + "-c", + "user.email=t@example.com", + "-c", + "commit.gpgsign=false", + "commit", + "-qm", + "init", + ], + ); + tmp + } + + fn folders(patterns: &str) -> Vec { + top_level_folders(patterns) + } + + #[test] + fn cone_entries_reduce_to_their_top_level_folder() { + assert_eq!(folders("jsoar-agent"), ["jsoar-agent"]); + assert_eq!(folders("services/payments"), ["services"]); + assert_eq!(folders("services/payments\nservices/ledger"), ["services"]); + assert_eq!(folders("jsoar-agent\ncommon"), ["common", "jsoar-agent"]); + } + + #[test] + fn non_cone_patterns_keep_only_folder_names() { + assert_eq!( + folders("/services/payments/\n*.md\n!/services/payments/tests/\n# note\n\n"), + ["services"] + ); + assert_eq!(folders("/api/**\n/web/*\n/docs/"), ["api", "docs", "web"]); + } + + #[test] + fn the_cone_mode_root_patterns_name_no_folder() { + assert!(folders("/*\n!/*/").is_empty()); + } + + #[test] + fn a_trailing_literal_newline_escape_is_dropped_but_inner_ones_are_kept() { + assert_eq!(folders("jsoar-agent\\n"), ["jsoar-agent"]); + assert_eq!(folders("jsoar-agent\\ncommon\\n"), ["jsoar-agent\\ncommon"]); + } + + #[test] + fn surrounding_whitespace_is_trimmed() { + assert_eq!( + folders(" jsoar-agent \r\n\tcommon\t"), + ["common", "jsoar-agent"] + ); + } + + #[test] + fn postfixed_name_joins_folders_with_dots() { + let name = |f: &[&str]| { + postfixed_name( + "monorepo", + &f.iter().map(|s| s.to_string()).collect::>(), + ) + }; + assert_eq!(name(&["jsoar-agent"]), "monorepo-jsoar-agent"); + assert_eq!( + name(&["common", "jsoar-agent"]), + "monorepo-common.jsoar-agent" + ); + } + + #[test] + fn postfixed_name_applies_the_template_character_rule_to_folders() { + let name = |f: &str| postfixed_name("monorepo", &[f.to_string()]); + assert_eq!(name("my svc"), "monorepo-my_svc"); + assert_eq!(name("a,b"), "monorepo-a.b"); + assert_eq!(name("\"caf\\303\\251\""), "monorepo-_caf_303_251_"); + assert_eq!(name("café"), "monorepo-caf__"); + } + + #[test] + fn postfixed_name_is_cut_at_the_template_length() { + let name = postfixed_name("monorepo", &["x".repeat(200)]); + assert_eq!(name.chars().count(), MAX_DERIVED_NAME_CHARS); + assert!(name.starts_with("monorepo-xxx")); + } + + #[test] + fn sparse_cone_checkout_yields_its_top_level_folders() { + let repo = monorepo(&["jsoar-agent", "common", "services/payments", "other"]); + git( + repo.path(), + &[ + "sparse-checkout", + "set", + "--cone", + "jsoar-agent", + "services/payments", + ], + ); + assert_eq!( + sparse_checkout_folders(repo.path()).unwrap(), + ["jsoar-agent", "services"] + ); + } + + #[test] + fn root_files_in_the_sparse_list_are_not_folders() { + for args in [ + &["--no-cone", "root.txt", "/services/**"][..], + // A `--no-checkout` clone, as CI sparse clones start, skips these + // checks without being asked. + &["--cone", "--skip-checks", "services", "root.txt"][..], + ] { + let repo = monorepo(&["services/payments"]); + let mut set = vec!["sparse-checkout", "set"]; + set.extend_from_slice(args); + git(repo.path(), &set); + assert_eq!( + sparse_checkout_folders(repo.path()).unwrap(), + ["services"], + "{args:?}" + ); + } + } + + #[test] + fn a_sparse_list_of_only_root_files_names_no_folder() { + let repo = monorepo(&["services/payments"]); + git( + repo.path(), + &["sparse-checkout", "set", "--no-cone", "root.txt"], + ); + let err = sparse_checkout_folders(repo.path()).unwrap_err(); + assert!(err.contains("no folder could be derived"), "{err}"); + } + + #[test] + fn non_ascii_folders_are_read_quoted_whatever_the_git_config_says() { + let repo = monorepo(&["café"]); + git(repo.path(), &["config", "core.quotePath", "false"]); + git(repo.path(), &["sparse-checkout", "set", "--cone", "café"]); + assert_eq!( + sparse_checkout_folders(repo.path()).unwrap(), + ["\"caf\\303\\251\""] + ); + } + + #[test] + fn a_full_checkout_is_not_sparse() { + let repo = monorepo(&["jsoar-agent"]); + let err = sparse_checkout_folders(repo.path()).unwrap_err(); + assert!(err.contains("not a sparse checkout"), "{err}"); + } + + #[test] + fn a_sparse_checkout_of_only_root_files_names_no_folder() { + let repo = monorepo(&["jsoar-agent"]); + git(repo.path(), &["sparse-checkout", "set", "--cone"]); + let err = sparse_checkout_folders(repo.path()).unwrap_err(); + assert!(err.contains("no folder could be derived"), "{err}"); + } + + #[test] + fn a_subdirectory_of_a_sparse_checkout_does_not_qualify() { + let repo = monorepo(&["jsoar-agent"]); + git( + repo.path(), + &["sparse-checkout", "set", "--cone", "jsoar-agent"], + ); + let err = sparse_checkout_folders(&repo.path().join("jsoar-agent")).unwrap_err(); + assert!(err.contains("not the root"), "{err}"); + } + + #[test] + fn an_explicit_project_name_is_kept_without_consulting_the_mode() { + assert_eq!( + resolve( + Some("mine".into()), + Some(ProjectNamingMode::SparsePostfixed) + ), + Some("mine".into()) + ); + assert_eq!(resolve(None, None), None); + } +} diff --git a/src/utils/generic.rs b/src/utils/generic.rs index a2d70f7..98d4d68 100644 --- a/src/utils/generic.rs +++ b/src/utils/generic.rs @@ -17,7 +17,7 @@ use zip::{write::FileOptions, ZipWriter}; /// of the worktree it was pointed at. `deps::run` scrubs the same set for its /// own git subprocesses; the library and binary crates share no module that /// could hold one copy. -const GIT_LOCAL_ENV_VARS: &[&str] = &[ +pub const GIT_LOCAL_ENV_VARS: &[&str] = &[ "GIT_ALTERNATE_OBJECT_DIRECTORIES", "GIT_CONFIG", "GIT_CONFIG_PARAMETERS", @@ -687,7 +687,7 @@ fn split_remote(url: &str) -> Option> { } /// True when `dir` is the repository worktree root (not a subdirectory). -fn is_at_repo_root(dir: &str) -> bool { +pub fn is_at_repo_root(dir: &str) -> bool { let Ok(repo) = Repository::discover(Path::new(dir)) else { return false; }; diff --git a/tests/project_naming_mode.rs b/tests/project_naming_mode.rs new file mode 100644 index 0000000..848a6d0 --- /dev/null +++ b/tests/project_naming_mode.rs @@ -0,0 +1,154 @@ +//! End-to-end tests for `--project-naming-mode`. + +mod common; + +use common::{projects_match, scans_one, temp_git_repo, Hits, Routes, CANON, REMOTE}; +use std::path::Path; +use std::process::{Command, Output}; + +/// `REMOTE`'s default project name plus the one folder the checkout keeps. +const SPARSE_NAME: &str = "dotnet-azure-web-tsb-jsoar-agent"; + +fn git(dir: &Path, args: &[&str]) { + let mut cmd = Command::new("git"); + for (name, _) in std::env::vars() { + if name.starts_with("GIT_") { + cmd.env_remove(name); + } + } + assert!( + cmd.args(args).current_dir(dir).status().unwrap().success(), + "git {args:?} failed" + ); +} + +/// A committed clone of `REMOTE` with a cone sparse checkout of `jsoar-agent`. +fn sparse_repo() -> (tempfile::TempDir, std::path::PathBuf) { + let (tmp, repo) = temp_git_repo("build-123", REMOTE); + for dir in ["jsoar-agent", "common"] { + std::fs::create_dir(repo.join(dir)).unwrap(); + std::fs::write(repo.join(dir).join("app.py"), "x = 1\n").unwrap(); + } + git(&repo, &["add", "-A"]); + git( + &repo, + &[ + "-c", + "user.name=t", + "-c", + "user.email=t@example.com", + "-c", + "commit.gpgsign=false", + "commit", + "-qm", + "init", + ], + ); + git(&repo, &["sparse-checkout", "set", "--cone", "jsoar-agent"]); + (tmp, repo) +} + +fn spawn_stub() -> (String, Hits) { + common::spawn_resolution_stub(Routes { + projects: Some(projects_match()), + scans: Some(scans_one(SPARSE_NAME)), + ..Default::default() + }) +} + +fn stderr(out: &Output) -> String { + String::from_utf8_lossy(&out.stderr).into_owned() +} + +#[test] +fn list_queries_the_sparse_postfixed_project_directly() { + let (url, hits) = spawn_stub(); + let (_tmp, repo) = sparse_repo(); + let out = common::run_corgea( + "list", + &["--project-naming-mode", "sparse-postfixed"], + &url, + &repo, + ); + assert_eq!(out.status.code(), Some(0), "stderr: {}", stderr(&out)); + assert!( + stderr(&out).contains(SPARSE_NAME), + "stderr: {}", + stderr(&out) + ); + let hits = hits.lock().unwrap(); + assert!( + hits.iter() + .any(|h| h.contains(&format!("project={SPARSE_NAME}"))), + "the derived name must drive /scans; hits: {hits:?}" + ); + assert!( + !hits.iter().any(|h| h.starts_with("/api/v1/projects")), + "a derived name is queried as given, not resolved by repo; hits: {hits:?}" + ); +} + +#[test] +fn list_outside_a_sparse_checkout_warns_and_resolves_as_usual() { + let (url, hits) = spawn_stub(); + let (_tmp, repo) = temp_git_repo("build-123", REMOTE); + let out = common::run_corgea( + "list", + &["--project-naming-mode", "sparse-postfixed"], + &url, + &repo, + ); + assert_eq!(out.status.code(), Some(0), "stderr: {}", stderr(&out)); + assert!( + stderr(&out).contains("not a sparse checkout"), + "stderr: {}", + stderr(&out) + ); + let hits = hits.lock().unwrap(); + assert!( + hits.iter() + .any(|h| h.contains(&format!("project={}", CANON.replace('/', "%2F")))), + "the repo-resolved project must drive /scans; hits: {hits:?}" + ); +} + +#[test] +fn the_mode_cannot_be_combined_with_an_explicit_project_name() { + let (_tmp, repo) = sparse_repo(); + for subcommand in ["scan", "upload", "list", "wait"] { + let out = common::run_corgea( + subcommand, + &[ + "--project-naming-mode", + "sparse-postfixed", + "--project-name", + "mine", + ], + "http://127.0.0.1:9", + &repo, + ); + assert_eq!(out.status.code(), Some(2), "{subcommand}: {}", stderr(&out)); + assert!( + stderr(&out).contains("cannot be used with"), + "{subcommand}: {}", + stderr(&out) + ); + } +} + +#[test] +fn an_unknown_mode_is_rejected_with_the_supported_values() { + let (_tmp, repo) = sparse_repo(); + let out = common::run_corgea( + "scan", + &["--project-naming-mode", "sparse"], + "http://127.0.0.1:9", + &repo, + ); + assert_eq!(out.status.code(), Some(2), "stderr: {}", stderr(&out)); + assert!( + stderr(&out).contains("sparse-postfixed"), + "stderr: {}", + stderr(&out) + ); +}