Skip to main content

kranz_cli/
init.rs

1//! Idempotent fresh-repository onboarding for `kranz init`.
2
3use anyhow::{anyhow, bail, Context, Result};
4use kranz_engine::cost;
5use kranz_engine::merge_gate::{parse_gate_suite, MERGE_GATES_PATH};
6use kranz_server::{HostConfig, MultiRepoHost};
7use serde_json::{json, Map, Value};
8use std::collections::HashSet;
9use std::fs;
10use std::io::Write;
11use std::path::{Component, Path, PathBuf};
12use std::process::Command;
13
14const RUNTIME_IGNORE_HEADER: &str = "# kranz runtime bookkeeping (generated by kranz init)";
15#[cfg(not(windows))]
16const RUST_TEST_GATE: &str = r#"output=$(mktemp) && trap 'rm -f "$output"' EXIT; cargo test --workspace >"$output" 2>&1; test_status=$?; cat "$output"; [ "$test_status" -eq 0 ] && grep -qE 'test result: ok\. [1-9][0-9]* passed' "$output""#;
17#[cfg(windows)]
18const RUST_TEST_GATE: &str = r#"powershell -NoProfile -Command "$output = cargo test --workspace 2>&1 | Out-String; $status = $LASTEXITCODE; Write-Output $output; if ($status -ne 0 -or $output -notmatch 'test result: ok\. [1-9][0-9]* passed') { exit 1 }""#;
19const RUNTIME_IGNORE_PATTERNS: &[&str] = &[
20    ".kranz/config.json",
21    ".kranz/missions/*/events.jsonl",
22    ".kranz/missions/*/events.jsonl.lock",
23    ".kranz/missions/*/state.json",
24    ".kranz/missions/*/state.json.tmp",
25    ".kranz/missions/*/estimate.json",
26    ".kranz/missions/*/enqueue-source*.json",
27    ".kranz/missions/*/control/",
28    ".kranz/missions/*/runs/",
29    ".kranz/missions/*/workspace/",
30    ".kranz/slack-threads.json",
31    ".kranz/slack/",
32    ".kranz/queue/",
33    ".kranz/hook-status/",
34    ".kranz/tickets/*.status",
35    ".kranz/serve.token",
36    ".kranz/serve.read.token",
37];
38
39#[derive(Debug, Clone, Default)]
40pub struct InitOptions {
41    /// Explicit unconditional gates. Empty means detect common toolchains.
42    pub gates: Vec<String>,
43    /// Optional operator-catalog registration.
44    pub registration: Option<Registration>,
45    /// Explicit global config path. Production supplies `~/.kranz/config.json`;
46    /// tests supply a temp path and never touch the real home directory.
47    pub global_config: Option<PathBuf>,
48}
49
50#[derive(Debug, Clone)]
51pub struct Registration {
52    pub id: Option<String>,
53    pub display_name: Option<String>,
54}
55
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub enum FileOutcome {
58    Created(PathBuf),
59    Updated(PathBuf),
60    Kept(PathBuf),
61}
62
63#[derive(Debug, Clone)]
64pub struct InitReport {
65    pub repo_root: PathBuf,
66    pub files: Vec<FileOutcome>,
67    pub gate_count: usize,
68    pub completed_missions: usize,
69    pub registered_repo: Option<(String, bool)>,
70}
71
72/// Prepare an existing Git worktree for Kranz. Existing merge gates are
73/// validated and retained byte-for-byte; other scaffolding is additive.
74pub fn initialize(repo: &Path, options: &InitOptions) -> Result<InitReport> {
75    let repo_root = exact_git_root(repo)?;
76    let kranz_dir = repo_root.join(".kranz");
77    let gates_path = repo_root.join(MERGE_GATES_PATH);
78    let (gate_bytes, gate_count, gate_exists) = prepare_gates(&repo_root, &gates_path, options)?;
79
80    let gitignore_path = repo_root.join(".gitignore");
81    let old_gitignore = match fs::read_to_string(&gitignore_path) {
82        Ok(text) => Some(text),
83        Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
84        Err(error) => {
85            return Err(error).with_context(|| format!("reading {}", gitignore_path.display()))
86        }
87    };
88    let new_gitignore = extend_gitignore(old_gitignore.as_deref().unwrap_or(""));
89
90    // Validate the complete global candidate before any filesystem mutation.
91    let registration = match options.registration.as_ref() {
92        Some(registration) => {
93            let path = options
94                .global_config
95                .as_deref()
96                .ok_or_else(|| anyhow!("cannot resolve the operator config path for --register"))?;
97            Some(prepare_registration(path, &repo_root, registration)?)
98        }
99        None => None,
100    };
101
102    fs::create_dir_all(kranz_dir.join("tickets"))
103        .with_context(|| format!("creating {}", kranz_dir.display()))?;
104
105    let mut files = Vec::new();
106    if gate_exists {
107        files.push(FileOutcome::Kept(gates_path.clone()));
108    } else {
109        atomic_write(&gates_path, &gate_bytes, false)?;
110        files.push(FileOutcome::Created(gates_path));
111    }
112
113    if old_gitignore.as_deref() == Some(new_gitignore.as_str()) {
114        files.push(FileOutcome::Kept(gitignore_path));
115    } else {
116        atomic_write(&gitignore_path, new_gitignore.as_bytes(), false)?;
117        files.push(if old_gitignore.is_some() {
118            FileOutcome::Updated(gitignore_path)
119        } else {
120            FileOutcome::Created(gitignore_path)
121        });
122    }
123
124    let keep_path = kranz_dir.join("tickets").join(".gitkeep");
125    if keep_path.exists() {
126        files.push(FileOutcome::Kept(keep_path));
127    } else {
128        atomic_write(&keep_path, b"", false)?;
129        files.push(FileOutcome::Created(keep_path));
130    }
131
132    let registered_repo = if let Some(candidate) = registration {
133        if candidate.changed {
134            let text = format!("{}\n", serde_json::to_string_pretty(&candidate.tree)?);
135            atomic_write(&candidate.path, text.as_bytes(), true)?;
136        }
137        Some((candidate.id, candidate.changed))
138    } else {
139        None
140    };
141
142    Ok(InitReport {
143        repo_root: repo_root.clone(),
144        files,
145        gate_count,
146        completed_missions: cost::calibrate(&repo_root).missions_used,
147        registered_repo,
148    })
149}
150
151pub fn render(report: &InitReport) -> String {
152    let mut out = format!("initialized {}\n", report.repo_root.display());
153    for file in &report.files {
154        let (verb, path) = match file {
155            FileOutcome::Created(path) => ("created", path),
156            FileOutcome::Updated(path) => ("updated", path),
157            FileOutcome::Kept(path) => ("kept", path),
158        };
159        let relative = path.strip_prefix(&report.repo_root).unwrap_or(path);
160        out.push_str(&format!("  {verb}: {}\n", relative.display()));
161    }
162    out.push_str(&format!(
163        "merge gates: {} configured gate(s)\n",
164        report.gate_count
165    ));
166    out.push_str("worker isolation: worktree (safe default)\n");
167    if report.completed_missions == 0 {
168        out.push_str(
169            "calibration: COLD START — 0 completed missions; estimates use built-in defaults\n",
170        );
171    } else {
172        out.push_str(&format!(
173            "calibration: {} completed mission(s) available\n",
174            report.completed_missions
175        ));
176    }
177    if let Some((id, changed)) = &report.registered_repo {
178        out.push_str(&format!(
179            "host catalog: {} repository '{id}'\n",
180            if *changed { "registered" } else { "kept" }
181        ));
182    }
183    out.push_str(
184        "next: review and commit .gitignore, .kranz/merge-gates.json, and .kranz/tickets/.gitkeep\n\
185         next: run `kranz ready`, then create a ticket with `kranz ticket new`\n",
186    );
187    out
188}
189
190fn exact_git_root(repo: &Path) -> Result<PathBuf> {
191    let requested = fs::canonicalize(repo)
192        .with_context(|| format!("cannot resolve repository path {}", repo.display()))?;
193    let output = Command::new("git")
194        .arg("-C")
195        .arg(&requested)
196        .args(["rev-parse", "--show-toplevel"])
197        .output()
198        .context("running git rev-parse --show-toplevel")?;
199    if !output.status.success() {
200        let detail = String::from_utf8_lossy(&output.stderr).trim().to_string();
201        bail!(
202            "{} is not a Git worktree{}",
203            requested.display(),
204            if detail.is_empty() {
205                String::new()
206            } else {
207                format!(": {detail}")
208            }
209        );
210    }
211    let detected = PathBuf::from(String::from_utf8(output.stdout)?.trim());
212    let detected = fs::canonicalize(&detected).unwrap_or(detected);
213    if requested != detected {
214        bail!(
215            "--repo must name the Git worktree root; {} belongs to {}",
216            requested.display(),
217            detected.display()
218        );
219    }
220    Ok(detected)
221}
222
223fn prepare_gates(
224    repo: &Path,
225    path: &Path,
226    options: &InitOptions,
227) -> Result<(Vec<u8>, usize, bool)> {
228    match fs::read(path) {
229        Ok(bytes) => {
230            let suite = parse_gate_suite(&bytes).map_err(anyhow::Error::msg)?;
231            return Ok((bytes, suite.gates.len(), true));
232        }
233        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
234        Err(error) => return Err(error).with_context(|| format!("reading {}", path.display())),
235    }
236
237    let gates = if options.gates.is_empty() {
238        detect_gates(repo)?
239    } else {
240        options.gates.clone()
241    };
242    if gates.is_empty() {
243        bail!("no validation commands detected; rerun with one or more `--gate <COMMAND>` values");
244    }
245    let value = json!({
246        "gates": gates
247            .iter()
248            .map(|command| json!({ "command": command }))
249            .collect::<Vec<_>>()
250    });
251    let bytes = format!("{}\n", serde_json::to_string_pretty(&value)?).into_bytes();
252    let suite = parse_gate_suite(&bytes).map_err(anyhow::Error::msg)?;
253    Ok((bytes, suite.gates.len(), false))
254}
255
256fn detect_gates(repo: &Path) -> Result<Vec<String>> {
257    let mut gates = Vec::new();
258    if repo.join("Cargo.toml").exists() {
259        gates.extend([
260            "cargo fmt --all --check".to_string(),
261            "cargo clippy --workspace --all-targets -- -D warnings".to_string(),
262            RUST_TEST_GATE.to_string(),
263            "cargo build --workspace".to_string(),
264        ]);
265    }
266
267    let package_json = repo.join("package.json");
268    if package_json.exists() {
269        let text = fs::read_to_string(&package_json)
270            .with_context(|| format!("reading {}", package_json.display()))?;
271        let package: Value = serde_json::from_str(&text)
272            .with_context(|| format!("invalid JSON in {}", package_json.display()))?;
273        let scripts = package.get("scripts").and_then(Value::as_object);
274        let (run, test) = if repo.join("pnpm-lock.yaml").exists() {
275            gates.push("pnpm install --frozen-lockfile".to_string());
276            ("pnpm run", "pnpm test")
277        } else if repo.join("yarn.lock").exists() {
278            gates.push("yarn install --frozen-lockfile".to_string());
279            ("yarn", "yarn test")
280        } else {
281            if repo.join("package-lock.json").exists() {
282                gates.push("npm ci".to_string());
283            }
284            ("npm run", "npm test")
285        };
286        for script in ["typecheck", "test", "build", "lint"] {
287            if scripts.is_some_and(|scripts| scripts.get(script).and_then(Value::as_str).is_some())
288            {
289                gates.push(if script == "test" {
290                    test.to_string()
291                } else {
292                    format!("{run} {script}")
293                });
294            }
295        }
296    }
297
298    if repo.join("pytest.ini").exists()
299        || repo.join("tox.ini").exists()
300        || pyproject_uses_pytest(&repo.join("pyproject.toml"))?
301    {
302        gates.push("python -m pytest".to_string());
303    }
304    Ok(gates)
305}
306
307fn pyproject_uses_pytest(path: &Path) -> Result<bool> {
308    let text = match fs::read_to_string(path) {
309        Ok(text) => text,
310        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
311        Err(error) => return Err(error).with_context(|| format!("reading {}", path.display())),
312    };
313    Ok(text.contains("[tool.pytest") || text.contains("pytest"))
314}
315
316fn extend_gitignore(existing: &str) -> String {
317    let present: HashSet<&str> = existing.lines().map(str::trim).collect();
318    let missing: Vec<_> = RUNTIME_IGNORE_PATTERNS
319        .iter()
320        .copied()
321        .filter(|pattern| !present.contains(pattern))
322        .collect();
323    if missing.is_empty() {
324        return existing.to_string();
325    }
326    let mut next = existing.to_string();
327    if !next.is_empty() && !next.ends_with('\n') {
328        next.push('\n');
329    }
330    if !next.is_empty() && !next.ends_with("\n\n") {
331        next.push('\n');
332    }
333    if !present.contains(RUNTIME_IGNORE_HEADER) {
334        next.push_str(RUNTIME_IGNORE_HEADER);
335        next.push('\n');
336    }
337    for pattern in missing {
338        next.push_str(pattern);
339        next.push('\n');
340    }
341    next
342}
343
344struct RegistrationCandidate {
345    path: PathBuf,
346    tree: Value,
347    id: String,
348    changed: bool,
349}
350
351fn prepare_registration(
352    path: &Path,
353    repo_root: &Path,
354    registration: &Registration,
355) -> Result<RegistrationCandidate> {
356    let mut tree = match fs::read_to_string(path) {
357        Ok(text) => serde_json::from_str::<Value>(&text)
358            .with_context(|| format!("invalid JSON in {}", path.display()))?,
359        Err(error) if error.kind() == std::io::ErrorKind::NotFound => json!({}),
360        Err(error) => return Err(error).with_context(|| format!("reading {}", path.display())),
361    };
362    let root = tree
363        .as_object_mut()
364        .ok_or_else(|| anyhow!("{} must contain a JSON object", path.display()))?;
365    let before = root.clone();
366    let host = root.entry("host").or_insert_with(|| json!({}));
367    let host = host
368        .as_object_mut()
369        .ok_or_else(|| anyhow!("host in {} must be a JSON object", path.display()))?;
370    host.entry("maxConcurrentRepos").or_insert(json!(1));
371
372    let id = registration
373        .id
374        .clone()
375        .unwrap_or_else(|| fallback_repo_id(repo_root));
376    let repos = host.entry("repos").or_insert_with(|| json!([]));
377    let repos = repos
378        .as_array_mut()
379        .ok_or_else(|| anyhow!("host.repos in {} must be an array", path.display()))?;
380
381    let canonical_root = canonical_or_lexical(repo_root);
382    let mut match_index = None;
383    for (index, value) in repos.iter().enumerate() {
384        let entry_id = value.get("id").and_then(Value::as_str);
385        let entry_root = value.get("root").and_then(Value::as_str).map(PathBuf::from);
386        let same_id = entry_id == Some(id.as_str());
387        let same_root =
388            entry_root.as_deref().map(canonical_or_lexical).as_ref() == Some(&canonical_root);
389        if same_id && !same_root {
390            bail!("host repository id '{id}' already names a different root");
391        }
392        if same_root && !same_id {
393            bail!(
394                "host repository root {} is already registered as '{}'",
395                canonical_root.display(),
396                entry_id.unwrap_or("<invalid>")
397            );
398        }
399        if same_id && same_root {
400            match_index = Some(index);
401        }
402    }
403
404    if let Some(index) = match_index {
405        if let Some(display_name) = registration.display_name.as_ref() {
406            repos[index]
407                .as_object_mut()
408                .ok_or_else(|| anyhow!("host repository '{id}' must be a JSON object"))?
409                .insert("displayName".into(), json!(display_name));
410        }
411    } else {
412        let mut entry = Map::from_iter([
413            ("id".into(), json!(id)),
414            (
415                "root".into(),
416                json!(canonical_root.to_string_lossy().into_owned()),
417            ),
418        ]);
419        if let Some(display_name) = registration.display_name.as_ref() {
420            entry.insert("displayName".into(), json!(display_name));
421        }
422        repos.push(Value::Object(entry));
423    }
424    // Elect a default only when this repository is the catalog's sole entry.
425    // An established multi-repo catalog with no defaultRepo is a deliberate
426    // state — the serve refuses to guess a target for unscoped routes — and
427    // registering one more repo must not silently retarget that refusal onto
428    // the newcomer.
429    let sole_entry = repos.len() == 1;
430    if sole_entry
431        && (!host.contains_key("defaultRepo") || host.get("defaultRepo") == Some(&Value::Null))
432    {
433        host.insert("defaultRepo".into(), json!(id));
434    }
435
436    let host_config: HostConfig = serde_json::from_value(Value::Object(host.clone()))
437        .with_context(|| format!("invalid host catalog in {}", path.display()))?;
438    MultiRepoHost::from_config(host_config)
439        .with_context(|| format!("invalid host catalog in {}", path.display()))?;
440
441    let changed = *root != before;
442    Ok(RegistrationCandidate {
443        path: path.to_path_buf(),
444        tree,
445        id,
446        changed,
447    })
448}
449
450fn fallback_repo_id(root: &Path) -> String {
451    let raw = root
452        .file_name()
453        .and_then(|name| name.to_str())
454        .unwrap_or("repo");
455    let mut id = String::new();
456    for character in raw.chars() {
457        if character.is_ascii_alphanumeric() || matches!(character, '-' | '_') {
458            id.push(character);
459        } else if !id.ends_with('-') {
460            id.push('-');
461        }
462    }
463    let id = id.trim_matches(|character| matches!(character, '-' | '_'));
464    if id.is_empty() {
465        "repo".to_string()
466    } else {
467        id.to_string()
468    }
469}
470
471fn canonical_or_lexical(path: &Path) -> PathBuf {
472    fs::canonicalize(path).unwrap_or_else(|_| {
473        let mut normalized = PathBuf::new();
474        for component in path.components() {
475            match component {
476                Component::CurDir => {}
477                Component::ParentDir => {
478                    normalized.pop();
479                }
480                other => normalized.push(other.as_os_str()),
481            }
482        }
483        normalized
484    })
485}
486
487fn atomic_write(path: &Path, bytes: &[u8], private: bool) -> Result<()> {
488    #[cfg(not(unix))]
489    let _ = private;
490    if let Some(parent) = path.parent() {
491        fs::create_dir_all(parent).with_context(|| format!("creating {}", parent.display()))?;
492    }
493    let file_name = path
494        .file_name()
495        .ok_or_else(|| anyhow!("path {} has no file name", path.display()))?;
496    let tmp = path.with_file_name(format!("{}.kranz-init.tmp", file_name.to_string_lossy()));
497    {
498        let mut file = fs::File::create(&tmp)
499            .with_context(|| format!("creating temporary file {}", tmp.display()))?;
500        file.write_all(bytes)
501            .with_context(|| format!("writing temporary file {}", tmp.display()))?;
502        file.sync_data()
503            .with_context(|| format!("syncing temporary file {}", tmp.display()))?;
504    }
505    #[cfg(unix)]
506    {
507        use std::os::unix::fs::PermissionsExt;
508        let mode = fs::metadata(path)
509            .map(|metadata| metadata.permissions().mode())
510            .unwrap_or(if private { 0o600 } else { 0o644 });
511        fs::set_permissions(&tmp, fs::Permissions::from_mode(mode))
512            .with_context(|| format!("setting permissions on {}", tmp.display()))?;
513    }
514    // POSIX rename replaces; on Windows the destination must be removed first
515    // (same pattern as crates/engine ticket/queue atomic writes).
516    match fs::rename(&tmp, path) {
517        Ok(()) => Ok(()),
518        Err(_) if cfg!(windows) && path.exists() => {
519            fs::remove_file(path)
520                .with_context(|| format!("removing {} before Windows replace", path.display()))?;
521            fs::rename(&tmp, path)
522                .with_context(|| format!("replacing {} from {}", path.display(), tmp.display()))
523        }
524        Err(error) => {
525            let _ = fs::remove_file(&tmp);
526            Err(error)
527                .with_context(|| format!("replacing {} from {}", path.display(), tmp.display()))
528        }
529    }
530}
531
532#[cfg(test)]
533mod tests {
534    use super::*;
535    use tempfile::TempDir;
536
537    fn git(repo: &Path, args: &[&str]) {
538        let output = Command::new("git")
539            .arg("-C")
540            .arg(repo)
541            .args(args)
542            .output()
543            .unwrap();
544        assert!(
545            output.status.success(),
546            "git {args:?}: {}",
547            String::from_utf8_lossy(&output.stderr)
548        );
549    }
550
551    fn repo(name: &str) -> (TempDir, PathBuf) {
552        let parent = TempDir::new().unwrap();
553        let root = parent.path().join(name);
554        fs::create_dir(&root).unwrap();
555        git(&root, &["init", "-q"]);
556        (parent, root)
557    }
558
559    #[test]
560    fn kranz_init_detects_node_gates_and_reports_cold_start() {
561        let (_parent, root) = repo("node-app");
562        fs::write(
563            root.join("package.json"),
564            r#"{"scripts":{"test":"vitest run","build":"vite build","lint":"oxlint"}}"#,
565        )
566        .unwrap();
567        fs::write(
568            root.join("package-lock.json"),
569            r#"{"lockfileVersion":3,"packages":{}}"#,
570        )
571        .unwrap();
572
573        let report = initialize(&root, &InitOptions::default()).unwrap();
574
575        assert_eq!(report.gate_count, 4);
576        assert_eq!(report.completed_missions, 0);
577        let gates = fs::read(root.join(MERGE_GATES_PATH)).unwrap();
578        let suite = parse_gate_suite(&gates).unwrap();
579        assert_eq!(suite.gates.len(), 4);
580        assert_eq!(suite.gates[0].command, "npm ci");
581        let rendered = render(&report);
582        assert!(rendered.contains("worktree (safe default)"));
583        assert!(rendered.contains("0 completed missions"));
584    }
585
586    #[test]
587    fn kranz_init_is_byte_idempotent_and_preserves_existing_content() {
588        let (_parent, root) = repo("rust-app");
589        fs::write(root.join("Cargo.toml"), "[workspace]\n").unwrap();
590        fs::write(root.join(".gitignore"), "target/\n").unwrap();
591        let options = InitOptions::default();
592        initialize(&root, &options).unwrap();
593        let first_ignore = fs::read(root.join(".gitignore")).unwrap();
594        let first_gates = fs::read(root.join(MERGE_GATES_PATH)).unwrap();
595        let suite = parse_gate_suite(&first_gates).unwrap();
596
597        assert_eq!(suite.gates[2].command, RUST_TEST_GATE);
598        #[cfg(not(windows))]
599        assert!(suite.gates[2].command.contains("grep -qE"));
600        #[cfg(windows)]
601        assert!(suite.gates[2].command.contains("-notmatch"));
602        assert!(suite.gates[2].command.contains("[1-9]"));
603
604        let second = initialize(&root, &options).unwrap();
605
606        assert_eq!(fs::read(root.join(".gitignore")).unwrap(), first_ignore);
607        assert_eq!(fs::read(root.join(MERGE_GATES_PATH)).unwrap(), first_gates);
608        assert!(String::from_utf8(first_ignore)
609            .unwrap()
610            .starts_with("target/\n"));
611        assert!(second
612            .files
613            .iter()
614            .all(|outcome| matches!(outcome, FileOutcome::Kept(_))));
615    }
616
617    #[test]
618    fn kranz_init_requires_an_explicit_gate_for_unknown_toolchains() {
619        let (_parent, root) = repo("unknown-app");
620        let error = initialize(&root, &InitOptions::default()).unwrap_err();
621        assert!(error.to_string().contains("--gate <COMMAND>"));
622        assert!(!root.join(".kranz").exists());
623
624        let report = initialize(
625            &root,
626            &InitOptions {
627                gates: vec!["make verify".into()],
628                ..InitOptions::default()
629            },
630        )
631        .unwrap();
632        assert_eq!(report.gate_count, 1);
633    }
634
635    #[test]
636    fn kranz_init_registers_canonical_root_without_losing_global_keys() {
637        let (parent, root) = repo("catalog-app");
638        fs::write(root.join("Cargo.toml"), "[workspace]\n").unwrap();
639        let global = parent.path().join("home/.kranz/config.json");
640        fs::create_dir_all(global.parent().unwrap()).unwrap();
641        fs::write(&global, r#"{"slack":{"botToken":"secret"}}"#).unwrap();
642        let options = InitOptions {
643            registration: Some(Registration {
644                id: Some("catalog-app".into()),
645                display_name: Some("Catalog App".into()),
646            }),
647            global_config: Some(global.clone()),
648            ..InitOptions::default()
649        };
650
651        let first = initialize(&root, &options).unwrap();
652        let second = initialize(&root, &options).unwrap();
653
654        assert_eq!(first.registered_repo, Some(("catalog-app".into(), true)));
655        assert_eq!(second.registered_repo, Some(("catalog-app".into(), false)));
656        let value: Value = serde_json::from_slice(&fs::read(global).unwrap()).unwrap();
657        assert_eq!(value["slack"]["botToken"], "secret");
658        assert_eq!(value["host"]["repos"].as_array().unwrap().len(), 1);
659        assert_eq!(
660            value["host"]["repos"][0]["root"],
661            canonical_or_lexical(&root).to_string_lossy().as_ref()
662        );
663        // The catalog's sole entry is elected default — single-repo catalogs
664        // keep the unscoped compatibility routes without extra configuration.
665        assert_eq!(value["host"]["defaultRepo"], "catalog-app");
666    }
667
668    #[test]
669    fn kranz_init_registration_never_elects_a_default_for_established_catalogs() {
670        let (parent, root) = repo("second-app");
671        fs::write(root.join("Cargo.toml"), "[workspace]\n").unwrap();
672        let existing = parent.path().join("existing");
673        fs::create_dir_all(&existing).unwrap();
674        let global = parent.path().join("home/.kranz/config.json");
675        fs::create_dir_all(global.parent().unwrap()).unwrap();
676        // Hand-authored catalog: one repo, deliberately no defaultRepo. The
677        // serve treats that as "refuse to guess" for unscoped routes;
678        // registering a newcomer must preserve the refusal, not adopt the
679        // newest repo as everyone's default.
680        fs::write(
681            &global,
682            serde_json::to_vec_pretty(&serde_json::json!({
683                "host": { "repos": [{ "id": "existing", "root": existing }] }
684            }))
685            .unwrap(),
686        )
687        .unwrap();
688        let options = InitOptions {
689            registration: Some(Registration {
690                id: Some("second-app".into()),
691                display_name: None,
692            }),
693            global_config: Some(global.clone()),
694            ..InitOptions::default()
695        };
696
697        initialize(&root, &options).unwrap();
698
699        let value: Value = serde_json::from_slice(&fs::read(&global).unwrap()).unwrap();
700        assert_eq!(value["host"]["repos"].as_array().unwrap().len(), 2);
701        assert!(value["host"].get("defaultRepo").is_none());
702    }
703
704    #[test]
705    fn kranz_init_registration_rejects_duplicate_ids_before_local_writes() {
706        let (parent, root) = repo("new-app");
707        fs::write(root.join("Cargo.toml"), "[workspace]\n").unwrap();
708        let other = parent.path().join("other");
709        fs::create_dir(&other).unwrap();
710        git(&other, &["init", "-q"]);
711        let global = parent.path().join("config.json");
712        fs::write(
713            &global,
714            format!(
715                r#"{{"host":{{"repos":[{{"id":"shared","root":{}}}]}}}}"#,
716                serde_json::to_string(&other).unwrap()
717            ),
718        )
719        .unwrap();
720        let error = initialize(
721            &root,
722            &InitOptions {
723                registration: Some(Registration {
724                    id: Some("shared".into()),
725                    display_name: None,
726                }),
727                global_config: Some(global),
728                ..InitOptions::default()
729            },
730        )
731        .unwrap_err();
732
733        assert!(error.to_string().contains("already names a different root"));
734        assert!(!root.join(".kranz").exists());
735    }
736}