diff --git a/src/cli.rs b/src/cli.rs index 68e30105..e8496b95 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -90,7 +90,13 @@ pub enum Command { batch: Vec, }, /// Create .specsync/config.toml and initialize the verified SDD layout - Init, + Init { + /// Repair a partially-initialized project: restore missing .specsync + /// support files (version stamp, .gitignore, sdd.json, directories) + /// without touching the existing config + #[arg(long)] + repair: bool, + }, /// Score spec quality (0-100) with letter grades and improvement suggestions Score { /// Show detailed per-category breakdown explaining exactly why each spec lost points @@ -205,7 +211,10 @@ pub enum Command { }, /// Quick-create a minimal spec for a module (auto-detects source files) New { - /// Module name for the new spec + /// Module name for the new spec. + /// Rules: 1-64 chars — letters, digits, `-`, `_`, `.`; must start with a + /// letter or digit; no spaces, path separators, or reserved names + /// (`change`, `specs`, Windows device names like `con`). name: String, /// Also create required companions (requirements.md, tasks.md, context.md, testing.md) /// and optional design.md when design artifacts are enabled diff --git a/src/commands/init.rs b/src/commands/init.rs index 7578f6b5..179abe8c 100644 --- a/src/commands/init.rs +++ b/src/commands/init.rs @@ -5,7 +5,7 @@ use std::io::IsTerminal; use std::path::Path; use std::process; -use crate::config::{config_to_toml, detect_source_dirs}; +use crate::config::{config_to_toml, detect_source_dirs_with_confidence}; use crate::types::SpecSyncConfig; /// Version stamp written to `.specsync/version` for fresh projects. @@ -19,18 +19,47 @@ const V4_DIRS: &[&str] = &[ ".specsync/archive/changes", ]; -pub fn cmd_init(root: &Path) { +pub fn cmd_init(root: &Path, repair: bool, format: crate::types::OutputFormat) { + let json = matches!(format, crate::types::OutputFormat::Json); // Refuse to clobber any existing config — current or legacy. let v4_toml = root.join(".specsync/config.toml"); let v4_json = root.join(".specsync/config.json"); let legacy_json = root.join("specsync.json"); let legacy_toml = root.join(".specsync.toml"); - if v4_toml.exists() { - println!(".specsync/config.toml already exists"); - return; - } - if v4_json.exists() { - println!(".specsync/config.json already exists"); + if v4_toml.exists() || v4_json.exists() { + let existing = if v4_toml.exists() { + ".specsync/config.toml" + } else { + ".specsync/config.json" + }; + if repair { + repair_layout(root, existing, json); + return; + } + let missing = missing_support_files(root); + if json { + println!( + "{}", + serde_json::json!({ + "created": false, + "config": existing, + "missing": missing, + "repair_hint": "specsync init --repair", + }) + ); + } else { + println!("{existing} already exists"); + if !missing.is_empty() { + println!( + " {} missing support file(s): {}", + "!".yellow(), + missing.join(", ") + ); + println!( + " Run `specsync init --repair` to restore them (your config is left untouched)." + ); + } + } return; } if legacy_json.exists() { @@ -42,7 +71,22 @@ pub fn cmd_init(root: &Path) { return; } - let detected_dirs = detect_source_dirs(root); + // Subdir footgun: running `init` inside a subdirectory of an initialized + // project must not create a nested .specsync — point at the parent instead. + if let Some(parent) = find_initialized_ancestor(root) { + eprintln!( + "{} A spec-sync project is already initialized at {} — no nested .specsync was created.", + "error:".red().bold(), + parent.display() + ); + eprintln!( + " Run from that root or pass `--root {}`.", + parent.display() + ); + process::exit(1); + } + + let (detected_dirs, actually_detected) = detect_source_dirs_with_confidence(root); let dirs_display = detected_dirs.join(", "); if let Err(e) = write_current_layout(root, &detected_dirs) { @@ -57,19 +101,184 @@ pub fn cmd_init(root: &Path) { process::exit(1); } - println!("{} Created .specsync/config.toml (5.0 layout)", "✓".green()); - println!(" Detected source directories: {dirs_display}"); + if json { + println!( + "{}", + serde_json::json!({ + "created": true, + "config": ".specsync/config.toml", + "source_dirs": detected_dirs, + "detected": actually_detected, + }) + ); + } else { + println!("{} Created .specsync/config.toml (5.0 layout)", "✓".green()); + if actually_detected { + println!(" Detected source directories: {dirs_display}"); + } else { + println!( + " {} No source directories detected — defaulted to source_dirs = [\"src\"].", + "!".yellow() + ); + println!(" Edit .specsync/config.toml if your sources live elsewhere."); + } + } // Ensure .specsync/hashes.json is gitignored (hash cache is local-only) match ensure_hashes_gitignored(root) { - Ok(true) => println!("{} Added .specsync/hashes.json to .gitignore", "✓".green()), - Ok(false) => {} + Ok(true) if !json => println!("{} Added .specsync/hashes.json to .gitignore", "✓".green()), + Ok(_) => {} Err(e) => eprintln!("{} {e}", "warning:".yellow().bold()), } guided_sdd_bootstrap(root); } +/// Support files a healthy `.specsync/` layout must have; missing ones are +/// reported by re-init and restored by `init --repair`. +fn missing_support_files(root: &Path) -> Vec { + let mut missing = Vec::new(); + for file in [ + ".specsync/version", + ".specsync/.gitignore", + ".specsync/sdd.json", + ] { + if !root.join(file).is_file() { + missing.push(file.to_string()); + } + } + for dir in V4_DIRS { + if !root.join(dir).is_dir() { + missing.push(format!("{dir}/")); + } + } + missing +} + +/// Restore missing `.specsync/` support files without touching the existing +/// config. Also sanity-checks that the config is at least plausible. +fn repair_layout(root: &Path, existing_config: &str, json: bool) { + let mut restored: Vec = Vec::new(); + let mut warnings: Vec = Vec::new(); + + // Sanity-check the existing config so a corrupt one is diagnosed, not ignored. + if let Ok(content) = fs::read_to_string(root.join(existing_config)) + && !content.contains("specs_dir") + { + warnings.push(format!( + "{existing_config} has no `specs_dir` key — it may be corrupt; fix or regenerate it manually" + )); + } + + for dir in V4_DIRS { + let path = root.join(dir); + if !path.is_dir() { + match fs::create_dir_all(&path) { + Ok(_) => restored.push(format!("{dir}/")), + Err(e) => { + eprintln!("{} Failed to create {dir}: {e}", "error:".red().bold()); + process::exit(1); + } + } + } + } + + let version_path = root.join(".specsync/version"); + if !version_path.is_file() { + match fs::write(&version_path, format!("{PROJECT_VERSION}\n")) { + Ok(_) => restored.push(".specsync/version".to_string()), + Err(e) => { + eprintln!("{} Failed to write .specsync/version: {e}", "error:".red().bold()); + process::exit(1); + } + } + } + + let gitignore_path = root.join(".specsync/.gitignore"); + if !gitignore_path.is_file() { + match fs::write(&gitignore_path, specsync_gitignore_content()) { + Ok(_) => restored.push(".specsync/.gitignore".to_string()), + Err(e) => { + eprintln!( + "{} Failed to write .specsync/.gitignore: {e}", + "error:".red().bold() + ); + process::exit(1); + } + } + } + + // sdd.json — write_default_policy is a no-op when the file exists. + let had_policy = root.join(".specsync/sdd.json").is_file(); + if let Err(error) = + crate::change::write_default_policy(root, crate::change::detect_verification_commands(root)) + { + eprintln!("{} {error}", "error:".red().bold()); + process::exit(1); + } + if !had_policy && root.join(".specsync/sdd.json").is_file() { + restored.push(".specsync/sdd.json".to_string()); + } + + match ensure_hashes_gitignored(root) { + Ok(true) => restored.push(".gitignore entry (.specsync/hashes.json)".to_string()), + Ok(false) => {} + Err(e) => warnings.push(e), + } + + if json { + println!( + "{}", + serde_json::json!({ + "repaired": restored, + "warnings": warnings, + }) + ); + } else { + if restored.is_empty() { + println!("{} Nothing to repair — .specsync layout is complete", "✓".green()); + } else { + println!("{} Repaired: {}", "✓".green(), restored.join(", ")); + } + for warning in &warnings { + eprintln!("{} {warning}", "warning:".yellow().bold()); + } + } +} + +/// Walk up from `root` to find an ancestor directory that already contains a +/// spec-sync project (any layout). Returns None when `root` is the topmost +/// initialized directory. +fn find_initialized_ancestor(root: &Path) -> Option { + let abs = root.canonicalize().ok()?; + for ancestor in abs.ancestors().skip(1) { + if ancestor.join(".specsync/config.toml").is_file() + || ancestor.join(".specsync/config.json").is_file() + || ancestor.join("specsync.json").is_file() + || ancestor.join(".specsync.toml").is_file() + { + return Some(ancestor.to_path_buf()); + } + } + None +} + +/// Contents of the generated `.specsync/.gitignore`. +fn specsync_gitignore_content() -> String { + [ + "# spec-sync 5.0 — generated by `specsync init`", + "# Committed: config.toml, registry.toml, lifecycle/, changes/, archive/", + "# Ignored: backups, local config, hash cache (regenerated on each run)", + "", + "backup-3x/", + "config.local.toml", + "hashes.json", + "change.lock", + "change-transaction.json", + "", + ] + .join("\n") +} fn guided_sdd_bootstrap(root: &Path) { if !std::io::stdin().is_terminal() || !std::io::stdout().is_terminal() { return; @@ -143,20 +352,7 @@ fn write_current_layout(root: &Path, source_dirs: &[String]) -> Result<(), Strin let gitignore_path = root.join(".specsync/.gitignore"); if !gitignore_path.exists() { - let content = [ - "# spec-sync 5.0 — generated by `specsync init`", - "# Committed: config.toml, registry.toml, lifecycle/, changes/, archive/", - "# Ignored: backups, local config, hash cache (regenerated on each run)", - "", - "backup-3x/", - "config.local.toml", - "hashes.json", - "change.lock", - "change-transaction.json", - "", - ] - .join("\n"); - fs::write(&gitignore_path, content) + fs::write(&gitignore_path, specsync_gitignore_content()) .map_err(|e| format!("Failed to write .specsync/.gitignore: {e}"))?; } @@ -256,7 +452,7 @@ mod tests { fn fresh_init_is_not_legacy_layout() { let tmp = TempDir::new().unwrap(); fs::create_dir_all(tmp.path().join("src")).unwrap(); - cmd_init(tmp.path()); + cmd_init(tmp.path(), false, crate::types::OutputFormat::Text); assert!( !crate::config::is_legacy_layout(tmp.path()), "fresh init must produce a 5.0 layout that does not trigger the migration nag" @@ -278,4 +474,45 @@ mod tests { ); } } + + #[test] + fn empty_dir_init_reports_fallback_not_detection() { + // #440: init must not claim it "detected" src in an empty directory. + let tmp = TempDir::new().unwrap(); + let (dirs, detected) = crate::config::detect_source_dirs_with_confidence(tmp.path()); + assert_eq!(dirs, vec!["src".to_string()]); + assert!(!detected, "empty dir must report fallback, not detection"); + } + + #[test] + fn repair_restores_deleted_support_files() { + // #440: re-init short-circuits; --repair restores version/.gitignore/sdd.json. + let tmp = TempDir::new().unwrap(); + let root = tmp.path(); + cmd_init(root, false, crate::types::OutputFormat::Text); + fs::remove_file(root.join(".specsync/version")).unwrap(); + fs::remove_file(root.join(".specsync/.gitignore")).unwrap(); + fs::remove_file(root.join(".specsync/sdd.json")).unwrap(); + assert_eq!(missing_support_files(root).len(), 3); + + cmd_init(root, true, crate::types::OutputFormat::Text); + + assert!(root.join(".specsync/version").is_file()); + assert!(root.join(".specsync/.gitignore").is_file()); + assert!(root.join(".specsync/sdd.json").is_file()); + assert!(missing_support_files(root).is_empty()); + } + + #[test] + fn find_initialized_ancestor_detects_parent_project() { + // #440: init in a subdir must find the parent project, not nest. + let tmp = TempDir::new().unwrap(); + let root = tmp.path(); + cmd_init(root, false, crate::types::OutputFormat::Text); + let sub = root.join("src/deep"); + fs::create_dir_all(&sub).unwrap(); + let found = find_initialized_ancestor(&sub).expect("parent project detected"); + assert_eq!(found, root.canonicalize().unwrap()); + assert!(find_initialized_ancestor(root).is_none()); + } } diff --git a/src/commands/init_registry.rs b/src/commands/init_registry.rs index 1b653516..863749c0 100644 --- a/src/commands/init_registry.rs +++ b/src/commands/init_registry.rs @@ -6,7 +6,8 @@ use std::process; use crate::config::load_config; use crate::registry; -pub fn cmd_init_registry(root: &Path, name: Option) { +pub fn cmd_init_registry(root: &Path, name: Option, format: crate::types::OutputFormat) { + let json = matches!(format, crate::types::OutputFormat::Json); // Respect the project layout: v4 projects get .specsync/registry.toml, // un-migrated 3.x projects keep the legacy root-level file. let registry_path = registry::local_registry_path(root); @@ -18,7 +19,15 @@ pub fn cmd_init_registry(root: &Path, name: Option) { .to_string() .replace('\\', "/"); if registry_path.exists() { - println!("{rel_display} already exists"); + if json { + println!( + "{}", + serde_json::json!({"created": false, "registry": rel_display}) + ); + } else { + println!("{rel_display} already exists — nothing to do"); + println!(" Delete it first (or edit it by hand) to regenerate."); + } return; } @@ -29,6 +38,13 @@ pub fn cmd_init_registry(root: &Path, name: Option) { .unwrap_or("project") .to_string() }); + if project_name.trim().is_empty() { + eprintln!( + "{} Registry name must not be empty — pass --name with a non-empty project name.", + "Error:".red() + ); + process::exit(1); + } let content = registry::generate_registry(root, &project_name, &config.specs_dir); if let Some(parent) = registry_path.parent() @@ -39,7 +55,14 @@ pub fn cmd_init_registry(root: &Path, name: Option) { } match fs::write(®istry_path, &content) { Ok(_) => { - println!("{} Created {rel_display}", "✓".green()); + if json { + println!( + "{}", + serde_json::json!({"created": true, "registry": rel_display, "name": project_name}) + ); + } else { + println!("{} Created {rel_display}", "✓".green()); + } } Err(e) => { eprintln!("Failed to write {rel_display}: {e}"); diff --git a/src/config.rs b/src/config.rs index f5718005..a7c58f4a 100644 --- a/src/config.rs +++ b/src/config.rs @@ -74,6 +74,38 @@ pub fn discover_manifest_modules(root: &Path) -> manifest::ManifestDiscovery { /// Scan-based source directory detection (fallback when no manifests found). fn detect_source_dirs_by_scan(root: &Path) -> Vec { + let detected = scan_source_dirs(root); + if detected.is_empty() { + // Fallback to "src" if nothing detected + return vec!["src".to_string()]; + } + detected +} + +/// Like [`detect_source_dirs`], but also reports whether any source +/// directories were actually detected. `false` means the `["src"]` fallback +/// was used — callers (e.g. `init`) must not present it as "detected". +pub fn detect_source_dirs_with_confidence(root: &Path) -> (Vec, bool) { + // Manifest-aware detection first + let manifest_discovery = manifest::discover_from_manifests(root); + if !manifest_discovery.source_dirs.is_empty() { + let mut dirs = manifest_discovery.source_dirs; + dirs.sort(); + dirs.dedup(); + return (dirs, true); + } + + let scanned = scan_source_dirs(root); + if scanned.is_empty() { + (vec!["src".to_string()], false) + } else { + (scanned, true) + } +} + +/// Raw directory scan with no fallback — returns an empty vector when no +/// source directories or root-level source files are found. +fn scan_source_dirs(root: &Path) -> Vec { let ignored: HashSet<&str> = IGNORED_DIRS.iter().copied().collect(); let mut source_dirs: Vec = Vec::new(); let mut has_root_source_files = false; @@ -81,7 +113,7 @@ fn detect_source_dirs_by_scan(root: &Path) -> Vec { // Check immediate children of root let entries = match fs::read_dir(root) { Ok(e) => e, - Err(_) => return vec!["src".to_string()], + Err(_) => return Vec::new(), }; for entry in entries.flatten() { @@ -110,15 +142,9 @@ fn detect_source_dirs_by_scan(root: &Path) -> Vec { return vec![".".to_string()]; } - if source_dirs.is_empty() { - // Fallback to "src" if nothing detected - return vec!["src".to_string()]; - } - source_dirs.sort(); source_dirs } - /// Check if a directory contains source files, scanning up to `max_depth` levels. fn dir_contains_source_files(dir: &Path, ignored: &HashSet<&str>, max_depth: usize) -> bool { for entry in WalkDir::new(dir) diff --git a/src/main.rs b/src/main.rs index 495b7c25..6c5b36f6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -100,7 +100,7 @@ fn run() { } match command { - Command::Init => commands::init::cmd_init(&root), + Command::Init { repair } => commands::init::cmd_init(&root, repair, format), Command::Check { fix, dry_run, @@ -167,7 +167,7 @@ fn run() { dir, template, } => commands::scaffold::cmd_scaffold(&root, &name, dir, template), - Command::InitRegistry { name } => commands::init_registry::cmd_init_registry(&root, name), + Command::InitRegistry { name } => commands::init_registry::cmd_init_registry(&root, name, format), Command::Resolve { remote, verify, diff --git a/src/registry.rs b/src/registry.rs index 3fed0ab2..04c19b38 100644 --- a/src/registry.rs +++ b/src/registry.rs @@ -156,8 +156,12 @@ pub fn fetch_remote_registry(repo: &str) -> Result { /// Matches inert 5.0.1-era placeholders such as `version = 1` plus an empty /// `[modules]` table that never carried module authority under 5.1.x parsing. pub fn is_inert_legacy_registry_stub(content: &str) -> bool { - let (name, specs) = scan_registry_fields(content); - name.is_empty() && specs.is_empty() + match parse_registry_toml(content) { + Ok((name, specs)) => name.is_empty() && specs.is_empty(), + // Malformed TOML is not inert — it must fail closed in + // load_local_registry, not silently vanish. + Err(_) => false, + } } /// Load the local registry with explicit missing/inert/invalid discrimination. @@ -189,49 +193,75 @@ pub fn load_registry(root: &Path) -> Option { load_local_registry(root).ok().flatten() } -/// Scan registry TOML for a `name` value and `[specs]` mappings. -fn scan_registry_fields(content: &str) -> (String, Vec<(String, String)>) { +/// Parse registry TOML into `(name, spec mappings)` with a real TOML parser. +/// +/// Two mapping shapes are supported: +/// - the generated `[specs]` table (`module = "path"`), emitted by +/// `init-registry` / `generate_registry` +/// - the documented `[[modules]]` array-of-tables (`name` + `spec` keys) from +/// `cross-project-refs.md` +/// +/// The registry `name` is read from `[registry] name` first, then a top-level +/// `name`. Returns `Err` on malformed TOML or malformed `[[modules]]` entries +/// so callers fail closed instead of silently dropping mappings. +fn parse_registry_toml(content: &str) -> Result<(String, Vec<(String, String)>), String> { + let value: toml::Value = + toml::from_str(content).map_err(|e| format!("invalid TOML: {e}"))?; + let mut name = String::new(); - let mut specs = Vec::new(); - let mut in_specs = false; + if let Some(n) = value.get("name").and_then(|v| v.as_str()) { + name = n.to_string(); + } + if let Some(n) = value + .get("registry") + .and_then(|r| r.get("name")) + .and_then(|v| v.as_str()) + { + name = n.to_string(); + } - for line in content.lines() { - let line = line.trim(); - if line.is_empty() || line.starts_with('#') { - continue; - } + let mut specs: Vec<(String, String)> = Vec::new(); - if line.starts_with('[') && line.ends_with(']') { - in_specs = line == "[specs]"; - continue; + // Generated shape: [specs] table of module = "path". + if let Some(table) = value.get("specs").and_then(|v| v.as_table()) { + for (module, path) in table { + if let Some(path) = path.as_str() { + specs.push((module.clone(), path.to_string())); + } } + } - if let Some(eq_pos) = line.find('=') { - let key = line[..eq_pos].trim(); - let value = line[eq_pos + 1..].trim(); - let value = value.trim_matches('"'); - - if in_specs { - specs.push((key.to_string(), value.to_string())); - } else if key == "name" && !value.is_empty() { - name = value.to_string(); + // Documented shape: [[modules]] array of { name, spec }. + if let Some(modules) = value.get("modules").and_then(|v| v.as_array()) { + for module in modules { + let module_name = module.get("name").and_then(|v| v.as_str()); + let spec_path = module.get("spec").and_then(|v| v.as_str()); + match (module_name, spec_path) { + (Some(m), Some(p)) => specs.push((m.to_string(), p.to_string())), + _ => { + return Err( + "every [[modules]] entry needs both `name` and `spec` keys".to_string() + ); + } } } } - (name, specs) + Ok((name, specs)) } /// Parse registry TOML content. +/// +/// Returns `None` when the content is malformed TOML or carries no registry +/// `name` — callers treat that as "failed to parse" and fail closed. fn parse_registry(content: &str) -> Option { - let (name, specs) = scan_registry_fields(content); + let (name, specs) = parse_registry_toml(content).ok()?; if name.is_empty() { return None; } Some(RegistryEntry { name, specs }) } - /// Generate a registry file by scanning for spec files. pub fn generate_registry(root: &Path, project_name: &str, specs_dir: &str) -> String { let specs_path = root.join(specs_dir); @@ -272,15 +302,35 @@ pub fn generate_registry(root: &Path, project_name: &str, specs_dir: &str) -> St let mut output = String::new(); output.push_str("[registry]\n"); - output.push_str(&format!("name = \"{project_name}\"\n")); + output.push_str(&format!("name = \"{}\"\n", toml_escape(project_name))); output.push_str("\n[specs]\n"); for (module, path) in &specs { - output.push_str(&format!("{module} = \"{path}\"\n")); + output.push_str(&format!("{module} = \"{}\"\n", toml_escape(path))); } output } +/// Escape a string for embedding inside a TOML basic string (`"..."`). +/// Without this, a project name containing `"`, `\`, or control characters +/// (e.g. newlines) produces a registry that fails TOML parsing — or worse, +/// injects extra keys into it. +fn toml_escape(value: &str) -> String { + let mut out = String::with_capacity(value.len()); + for ch in value.chars() { + match ch { + '\\' => out.push_str("\\\\"), + '"' => out.push_str("\\\""), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + c if c.is_control() => out.push_str(&format!("\\u{:04X}", c as u32)), + c => out.push(c), + } + } + out +} + /// Add a module entry to an existing local registry file /// (`.specsync/registry.toml`, falling back to legacy `specsync-registry.toml`). /// If the module already exists, it is not duplicated. @@ -449,4 +499,50 @@ messaging = "specs/messaging/messaging.spec.md" assert!(reg.has_spec("messaging")); assert!(!reg.has_spec("nonexistent")); } + + #[test] + fn generate_registry_escapes_toml_injection() { + // #440: a --name with quotes/newlines must produce valid TOML. + let tmp = tempfile::TempDir::new().unwrap(); + let content = generate_registry(tmp.path(), "evil\"\n[specs]\npwned=\"x", "specs"); + let parsed: Result = toml::from_str(&content); + assert!(parsed.is_ok(), "generated registry must be valid TOML: {content}"); + // No injected [specs] key survived. + let value = parsed.unwrap(); + assert!(value.get("specs").is_none() || value["specs"].get("pwned").is_none()); + } + + #[test] + fn parse_registry_supports_documented_modules_shape() { + // #413 facet 1: [[modules]] array-of-tables must not drop mappings. + let content = "[registry]\nname = \"probe\"\n\n[[modules]]\nname = \"lib\"\nspec = \"custom/lib.spec.md\"\n"; + let entry = parse_registry(content).expect("documented shape parses"); + assert_eq!(entry.name, "probe"); + assert_eq!( + entry.specs, + vec![("lib".to_string(), "custom/lib.spec.md".to_string())] + ); + } + + #[test] + fn load_local_registry_fails_closed_on_malformed_toml_with_name_line() { + // #413 facet 2: `name = {{{` is not valid TOML and must not parse. + let temp = tempfile::TempDir::new().unwrap(); + let root = temp.path(); + fs::create_dir_all(root.join(".specsync")).unwrap(); + fs::write(root.join(".specsync/registry.toml"), "version = 1\nname = {{{\n").unwrap(); + + assert!(!is_inert_legacy_registry_stub( + "version = 1\nname = {{{\n" + )); + let error = load_local_registry(root).unwrap_err(); + assert!(error.contains("failed to parse local registry"), "{error}"); + } + + #[test] + fn modules_entry_missing_spec_key_is_an_error() { + let content = "[registry]\nname = \"probe\"\n\n[[modules]]\nname = \"lib\"\n"; + assert!(parse_registry(content).is_none()); + assert!(!is_inert_legacy_registry_stub(content)); + } }