1use 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 pub gates: Vec<String>,
43 pub registration: Option<Registration>,
45 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
72pub 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 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 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 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 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 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}