1use std::env;
2use std::ffi::OsString;
3use std::fmt::Write as _;
4use std::io::IsTerminal;
5use std::io::Read;
6use std::path::Path;
7use std::process::Command;
8
9use crate::config::{CONFIG_FILE_NAME, split_command_words};
10use crate::error::{DdlError, Result};
11use crate::log_ui::{LogUiExit, run_log_ui};
12use crate::presentation::{
13 RecoveryCapability, continuation_label, format_absolute_time, latest_action_label,
14 recovery_capability, session_status_label, session_title, tool_event_label, tool_event_preview,
15};
16use crate::store::{DaedalusStore, InitOutcome};
17
18pub fn run_cli(args: impl IntoIterator<Item = OsString>) -> Result<i32> {
19 let arguments = parse_arguments(args)?;
20
21 match arguments {
22 CommandLine::Help => {
23 print_help();
24 Ok(0)
25 }
26 CommandLine::Init => {
27 let store = DaedalusStore::discover()?;
28 let outcome = store.init()?;
29 print!("{}", render_init_success(&store, outcome)?);
30 Ok(0)
31 }
32 CommandLine::Config { action } => {
33 let store = DaedalusStore::discover()?;
34 match action {
35 ConfigAction::Show => print!("{}", render_config(&store)?),
36 ConfigAction::Path => {
37 let _ = store.read_config_text()?;
38 println!("{}", store.resolved_config_path()?.display());
39 }
40 ConfigAction::Edit => edit_config(&store)?,
41 }
42 Ok(0)
43 }
44 CommandLine::Where => {
45 let store = DaedalusStore::discover()?;
46 print!("{}", render_where(&store)?);
47 Ok(0)
48 }
49 CommandLine::Run { command } => {
50 let store = DaedalusStore::discover()?;
51 let result = store.run_agent(command)?;
52 if let Some(checkpoint_id) = result.latest_checkpoint_id {
53 println!(
54 "run {} finished on timeline {} with latest checkpoint {} and session head {}",
55 result.run_id,
56 result.timeline_id,
57 checkpoint_id,
58 result.head_checkpoint_id.as_deref().unwrap_or("(missing)")
59 );
60 } else if let Some(head_checkpoint_id) = result.head_checkpoint_id {
61 println!(
62 "run {} finished on timeline {} with session head {}",
63 result.run_id, result.timeline_id, head_checkpoint_id
64 );
65 } else {
66 println!(
67 "run {} finished on timeline {} with no checkpoints yet",
68 result.run_id, result.timeline_id
69 );
70 }
71 Ok(result.exit_code)
72 }
73 CommandLine::Shell { command } => {
74 let store = DaedalusStore::discover()?;
75 store.run_shell_command(command)
76 }
77 CommandLine::ClaudePreToolUseHook => {
78 let mut input = String::new();
79 std::io::stdin().read_to_string(&mut input)?;
80 let store = DaedalusStore::discover()?;
81 store.handle_claude_pre_tool_use(&input)
82 }
83 CommandLine::Log => {
84 let store = DaedalusStore::discover()?;
85 if std::io::stdin().is_terminal() && std::io::stdout().is_terminal() {
86 match run_log_ui(&store)? {
87 LogUiExit::Quit => Ok(0),
88 LogUiExit::Rewind(checkpoint_id) => store.rewind(&checkpoint_id),
89 }
90 } else {
91 print_log(&store)?;
92 Ok(0)
93 }
94 }
95 CommandLine::Diff {
96 checkpoint_a,
97 checkpoint_b,
98 } => {
99 let store = DaedalusStore::discover()?;
100 let (a, b) = resolve_diff_targets(&store, checkpoint_a, checkpoint_b)?;
101 let output = store.diff(&a, &b)?;
102 if output.trim().is_empty() {
103 println!("no differences between {a} and {b}");
104 } else {
105 print!("{output}");
106 }
107 Ok(0)
108 }
109 CommandLine::Restore { checkpoint } => {
110 let store = DaedalusStore::discover()?;
111 store.restore(&checkpoint)?;
112 println!("restored workspace to checkpoint {checkpoint}");
113 Ok(0)
114 }
115 CommandLine::Rewind { checkpoint } => {
116 let store = DaedalusStore::discover()?;
117 store.rewind(&checkpoint)
118 }
119 }
120}
121
122#[derive(Debug)]
123enum CommandLine {
124 Help,
125 Init,
126 Config {
127 action: ConfigAction,
128 },
129 Where,
130 Run {
131 command: Vec<String>,
132 },
133 Shell {
134 command: Vec<String>,
135 },
136 ClaudePreToolUseHook,
137 Log,
138 Diff {
139 checkpoint_a: Option<String>,
140 checkpoint_b: Option<String>,
141 },
142 Restore {
143 checkpoint: String,
144 },
145 Rewind {
146 checkpoint: String,
147 },
148}
149
150#[derive(Debug, Eq, PartialEq)]
151enum ConfigAction {
152 Show,
153 Path,
154 Edit,
155}
156
157fn parse_arguments(args: impl IntoIterator<Item = OsString>) -> Result<CommandLine> {
158 let parts = args
159 .into_iter()
160 .map(|item| item.to_string_lossy().to_string())
161 .collect::<Vec<_>>();
162
163 if parts.len() <= 1 {
164 return Ok(CommandLine::Help);
165 }
166
167 match parts[1].as_str() {
168 "-h" | "--help" | "help" => Ok(CommandLine::Help),
169 "init" => Ok(CommandLine::Init),
170 "config" => parse_config(parts),
171 "where" => Ok(CommandLine::Where),
172 "log" => Ok(CommandLine::Log),
173 "internal" => parse_internal(parts),
174 "run" => parse_run(parts),
175 "shell" => parse_shell(parts),
176 "diff" => parse_diff(parts),
177 "restore" => parse_single_value(parts, "restore")
178 .map(|checkpoint| CommandLine::Restore { checkpoint }),
179 "rewind" => parse_single_value(parts, "rewind")
180 .map(|checkpoint| CommandLine::Rewind { checkpoint }),
181 "resume" => Err(DdlError::InvalidInput(
182 "`ddl resume` was removed; use `ddl rewind <checkpoint_id>` for agent-context rewind or `ddl restore <checkpoint_id>` for repo-only recovery".to_string(),
183 )),
184 other => Err(DdlError::InvalidInput(format!(
185 "unknown command `{other}`; run `ddl --help` for usage"
186 ))),
187 }
188}
189
190fn parse_config(parts: Vec<String>) -> Result<CommandLine> {
191 let action = match parts.get(2).map(String::as_str) {
192 None => ConfigAction::Show,
193 Some("path") => ConfigAction::Path,
194 Some("edit") => ConfigAction::Edit,
195 Some(other) => {
196 return Err(DdlError::InvalidInput(format!(
197 "unknown config command `{other}`; usage: ddl config [path|edit]"
198 )));
199 }
200 };
201
202 if parts.len() > 3 {
203 return Err(DdlError::InvalidInput(
204 "usage: ddl config [path|edit]".to_string(),
205 ));
206 }
207
208 Ok(CommandLine::Config { action })
209}
210
211fn parse_internal(parts: Vec<String>) -> Result<CommandLine> {
212 match parts.get(2).map(String::as_str) {
213 Some("claude-pre-tool-use") => Ok(CommandLine::ClaudePreToolUseHook),
214 Some(other) => Err(DdlError::InvalidInput(format!(
215 "unknown internal command `{other}`"
216 ))),
217 None => Err(DdlError::InvalidInput(
218 "missing internal command".to_string(),
219 )),
220 }
221}
222
223fn parse_run(parts: Vec<String>) -> Result<CommandLine> {
224 if parts.len() < 4 || parts[2] != "--" {
225 return Err(DdlError::InvalidInput(
226 "usage: ddl run -- claude <args...>".to_string(),
227 ));
228 }
229
230 Ok(CommandLine::Run {
231 command: parts.into_iter().skip(3).collect(),
232 })
233}
234
235fn parse_shell(parts: Vec<String>) -> Result<CommandLine> {
236 if parts.len() < 4 || parts[2] != "--" {
237 return Err(DdlError::InvalidInput(
238 "usage: ddl shell -- <command>".to_string(),
239 ));
240 }
241
242 Ok(CommandLine::Shell {
243 command: parts.into_iter().skip(3).collect(),
244 })
245}
246
247fn parse_diff(parts: Vec<String>) -> Result<CommandLine> {
248 match parts.len() {
249 2 => Ok(CommandLine::Diff {
250 checkpoint_a: None,
251 checkpoint_b: None,
252 }),
253 4 => Ok(CommandLine::Diff {
254 checkpoint_a: Some(parts[2].clone()),
255 checkpoint_b: Some(parts[3].clone()),
256 }),
257 _ => Err(DdlError::InvalidInput(
258 "usage: ddl diff [checkpoint_a] [checkpoint_b]".to_string(),
259 )),
260 }
261}
262
263fn parse_single_value(parts: Vec<String>, name: &str) -> Result<String> {
264 if parts.len() != 3 {
265 return Err(DdlError::InvalidInput(format!(
266 "usage: ddl {name} <checkpoint_id>"
267 )));
268 }
269 Ok(parts[2].clone())
270}
271
272fn resolve_diff_targets(
273 store: &DaedalusStore,
274 checkpoint_a: Option<String>,
275 checkpoint_b: Option<String>,
276) -> Result<(String, String)> {
277 match (checkpoint_a, checkpoint_b) {
278 (Some(a), Some(b)) => Ok((a, b)),
279 (None, None) => {
280 let checkpoints = store.list_checkpoints()?;
281 if checkpoints.len() < 2 {
282 return Err(DdlError::InvalidInput(
283 "need at least two checkpoints to diff".to_string(),
284 ));
285 }
286 let a = checkpoints[checkpoints.len() - 2].id.clone();
287 let b = checkpoints[checkpoints.len() - 1].id.clone();
288 Ok((a, b))
289 }
290 _ => Err(DdlError::InvalidInput(
291 "either pass both checkpoint ids or neither".to_string(),
292 )),
293 }
294}
295
296fn print_log(store: &DaedalusStore) -> Result<()> {
297 print!("{}", render_log(store)?);
298 Ok(())
299}
300
301fn render_init_success(store: &DaedalusStore, outcome: InitOutcome) -> Result<String> {
302 let state_dir = store.resolved_state_dir()?;
303 let config_path = state_dir.join(CONFIG_FILE_NAME);
304 let mut output = String::new();
305 let status = match outcome {
306 InitOutcome::Initialized => "initialized daedalus state in",
307 InitOutcome::AlreadyInitialized => "daedalus state already initialized in",
308 };
309 let _ = writeln!(output, "{status} {}", state_dir.display());
310 let _ = writeln!(
311 output,
312 "run `ddl config` to inspect rules or `ddl config edit` to change them"
313 );
314 let _ = writeln!(output, "config path: {}", config_path.display());
315 Ok(output)
316}
317
318fn render_config(store: &DaedalusStore) -> Result<String> {
319 let state_id = store.state_id()?;
320 let config_path = store.resolved_config_path()?;
321 let config_text = store.read_config_text()?;
322 let mut output = String::new();
323 let _ = writeln!(output, "Repo root: {}", store.repo_root().display());
324 let _ = writeln!(output, "State id: {state_id}");
325 let _ = writeln!(output, "Config path: {}", config_path.display());
326 output.push('\n');
327 output.push_str(&config_text);
328 if !config_text.ends_with('\n') {
329 output.push('\n');
330 }
331 Ok(output)
332}
333
334fn edit_config(store: &DaedalusStore) -> Result<()> {
335 let config_path = store.resolved_config_path()?;
336 let _ = store.read_config_text()?;
337 let command_argv = editor_command_argv(env::var("EDITOR").ok().as_deref(), &config_path)?;
338 let program = command_argv[0].clone();
339 let status = Command::new(&program).args(&command_argv[1..]).status()?;
340 if status.success() {
341 Ok(())
342 } else {
343 Err(DdlError::CommandFailed {
344 program,
345 status: status.code(),
346 stderr: String::new(),
347 })
348 }
349}
350
351fn editor_command_argv(editor: Option<&str>, path: &Path) -> Result<Vec<String>> {
352 let editor = editor
353 .map(str::trim)
354 .filter(|value| !value.is_empty())
355 .ok_or_else(|| {
356 DdlError::InvalidInput(
357 "EDITOR is not set; set `EDITOR` or use `ddl config path` to open the file manually"
358 .to_string(),
359 )
360 })?;
361 let mut argv = split_command_words(editor).map_err(|error| {
362 DdlError::InvalidInput(format!("invalid EDITOR value `{editor}`: {error}"))
363 })?;
364 if argv.is_empty() {
365 return Err(DdlError::InvalidInput(
366 "EDITOR is not set; set `EDITOR` or use `ddl config path` to open the file manually"
367 .to_string(),
368 ));
369 }
370 argv.push(path.display().to_string());
371 Ok(argv)
372}
373
374fn render_where(store: &DaedalusStore) -> Result<String> {
375 let state_dir = store.resolved_state_dir()?;
376 let state_id = store.state_id()?;
377 let config_path = store.resolved_config_path()?;
378 let metadata_path = state_dir.join("store.meta");
379 let mut output = String::new();
380 let _ = writeln!(output, "Repo root: {}", store.repo_root().display());
381 let _ = writeln!(output, "State id: {state_id}");
382 let _ = writeln!(output, "State dir: {}", state_dir.display());
383 let _ = writeln!(output, "Config: {}", config_path.display());
384 let _ = writeln!(output, "Metadata: {}", metadata_path.display());
385 Ok(output)
386}
387
388fn render_log(store: &DaedalusStore) -> Result<String> {
389 let timelines = store.list_timelines()?;
390 let checkpoints = store.list_checkpoints()?;
391 let checkpoint_by_id = checkpoints
392 .iter()
393 .map(|checkpoint| (checkpoint.id.as_str(), checkpoint))
394 .collect::<std::collections::BTreeMap<_, _>>();
395 let mut output = String::new();
396
397 if timelines.is_empty() {
398 output.push_str("no sessions recorded\n");
399 return Ok(output);
400 }
401
402 output.push_str("Recent Sessions\n");
403 for timeline in timelines.into_iter().rev() {
404 let run = store.read_run(&timeline.run_id)?;
405 let mut session_checkpoints = checkpoints
406 .iter()
407 .filter(|checkpoint| checkpoint.timeline_id == timeline.id)
408 .collect::<Vec<_>>();
409 session_checkpoints.sort_by_key(|checkpoint| checkpoint.created_at);
410 let session_head = run.head_checkpoint_id.as_deref().and_then(|head_id| {
411 session_checkpoints
412 .iter()
413 .copied()
414 .find(|item| item.id == head_id)
415 });
416 let protected_actions = session_checkpoints
417 .iter()
418 .copied()
419 .filter(|checkpoint| checkpoint.kind == crate::model::CheckpointKind::ProtectedAction)
420 .collect::<Vec<_>>();
421 let mut recovery_points = Vec::new();
422 if let Some(head) = session_head {
423 recovery_points.push(head);
424 }
425 recovery_points.extend(protected_actions.iter().rev().copied());
426 let latest_checkpoint = protected_actions.last().copied();
427 let capability = recovery_points
428 .first()
429 .copied()
430 .map(recovery_capability)
431 .unwrap_or(RecoveryCapability::Unavailable);
432 let continuation = continuation_label(
433 run.rewind_source_checkpoint_id
434 .as_deref()
435 .and_then(|checkpoint_id| checkpoint_by_id.get(checkpoint_id).copied()),
436 );
437
438 let _ = writeln!(output, "{}", session_title(&timeline, &run));
439 let _ = writeln!(
440 output,
441 " Started: {} | {} protected actions | {}",
442 format_absolute_time(timeline.created_at),
443 protected_actions.len(),
444 capability.label()
445 );
446 let _ = writeln!(
447 output,
448 " Status: {} | Latest protected action: {}",
449 session_status_label(&run.status),
450 latest_checkpoint
451 .map(|item| latest_action_label(Some(item)))
452 .unwrap_or_else(|| latest_action_label(None),)
453 );
454 if let Some(continuation) = continuation {
455 let _ = writeln!(output, " {continuation}");
456 }
457
458 if recovery_points.is_empty() {
459 output.push_str(" No recovery points recorded yet.\n");
460 } else {
461 output.push_str(" Recovery points:\n");
462 for checkpoint in recovery_points {
463 let _ = writeln!(
464 output,
465 " {} | {} | {}",
466 tool_event_label(checkpoint),
467 crate::presentation::format_relative_time(checkpoint.created_at),
468 recovery_capability(checkpoint).label()
469 );
470 if let Some(preview) = tool_event_preview(checkpoint) {
471 let _ = writeln!(output, " {preview}");
472 }
473 }
474 }
475 }
476
477 Ok(output)
478}
479
480fn print_help() {
481 println!(
482 "\
483daedalus v1 CLI
484
485Usage:
486 ddl init
487 ddl config [path|edit]
488 ddl where
489 ddl run -- claude <args...>
490 ddl shell -- <command>
491 ddl log
492 ddl diff [checkpoint_a] [checkpoint_b]
493 ddl restore <checkpoint_id>
494 ddl rewind <checkpoint_id>
495"
496 );
497}
498
499#[cfg(test)]
500mod tests {
501 use std::ffi::OsString;
502 use std::fs;
503 use std::path::{Path, PathBuf};
504 use std::process::Command;
505
506 use crate::model::{
507 CheckpointKind, CheckpointRecord, Resumability, RunRecord, RunStatus, RuntimeFingerprint,
508 TimelineRecord,
509 };
510 use crate::store::{DaedalusStore, InitOutcome};
511
512 use super::{
513 CommandLine, ConfigAction, editor_command_argv, parse_arguments, render_config,
514 render_init_success, render_log, render_where,
515 };
516
517 #[test]
518 fn log_surfaces_claude_rewind_availability() {
519 let repo_root = create_temp_repo("app-log");
520 let store = DaedalusStore::discover_from(&repo_root).expect("discover store");
521 store.init().expect("initialize store");
522 let state_dir = store.state_dir().to_path_buf();
523
524 let created_at = 1;
525 TimelineRecord {
526 id: "tl_test".to_string(),
527 run_id: "run_test".to_string(),
528 created_at,
529 }
530 .write(&state_dir.join("timelines/tl_test.meta"))
531 .expect("write timeline");
532 RunRecord {
533 id: "run_test".to_string(),
534 timeline_id: "tl_test".to_string(),
535 command: vec!["claude".to_string()],
536 created_at,
537 status: RunStatus::Running,
538 last_checkpoint_id: Some("cp_test".to_string()),
539 head_checkpoint_id: Some("cp_head".to_string()),
540 rewind_source_checkpoint_id: None,
541 resumability: Resumability::Full,
542 }
543 .write(&state_dir.join("runs/run_test.meta"))
544 .expect("write run");
545 let snapshot_path = state_dir.join("shadow/snapshots/cp_test");
546 fs::create_dir_all(&snapshot_path).expect("create snapshot");
547 let head_snapshot_path = state_dir.join("shadow/snapshots/cp_head");
548 fs::create_dir_all(&head_snapshot_path).expect("create head snapshot");
549 let rewind_path = state_dir.join("runtime/run_test/claude-checkpoints/cp_full");
550 fs::create_dir_all(&rewind_path).expect("create rewind snapshot");
551 fs::write(rewind_path.join("marker.txt"), "saved").expect("write rewind marker");
552 CheckpointRecord {
553 id: "cp_test".to_string(),
554 timeline_id: "tl_test".to_string(),
555 run_id: "run_test".to_string(),
556 kind: CheckpointKind::ProtectedAction,
557 parent_checkpoint_id: None,
558 reason: "before-edit".to_string(),
559 snapshot_rel_path: "snapshots/cp_test".to_string(),
560 shadow_commit: "deadbeef".to_string(),
561 created_at,
562 resumability: Resumability::Partial,
563 trigger_tool_type: Some("edit".to_string()),
564 trigger_command: Some("src/main.rs".to_string()),
565 runtime_name: Some("claude".to_string()),
566 claude_session_id: None,
567 claude_rewind_rel_path: None,
568 fingerprint: RuntimeFingerprint {
569 cwd: repo_root.display().to_string(),
570 repo_root: repo_root.display().to_string(),
571 git_head: "deadbeef".to_string(),
572 git_branch: "main".to_string(),
573 git_dirty: false,
574 git_version: "git version".to_string(),
575 },
576 }
577 .write(&state_dir.join("checkpoints/cp_test.meta"))
578 .expect("write checkpoint");
579 CheckpointRecord {
580 id: "cp_head".to_string(),
581 timeline_id: "tl_test".to_string(),
582 run_id: "run_test".to_string(),
583 kind: CheckpointKind::SessionHead,
584 parent_checkpoint_id: Some("cp_test".to_string()),
585 reason: "session-head".to_string(),
586 snapshot_rel_path: "snapshots/cp_head".to_string(),
587 shadow_commit: "cafebabe".to_string(),
588 created_at: created_at + 1,
589 resumability: Resumability::Full,
590 trigger_tool_type: None,
591 trigger_command: None,
592 runtime_name: Some("claude".to_string()),
593 claude_session_id: Some("11111111-1111-4111-8111-111111111111".to_string()),
594 claude_rewind_rel_path: Some("runtime/run_test/claude-checkpoints/cp_full".to_string()),
595 fingerprint: RuntimeFingerprint {
596 cwd: repo_root.display().to_string(),
597 repo_root: repo_root.display().to_string(),
598 git_head: "cafebabe".to_string(),
599 git_branch: "main".to_string(),
600 git_dirty: false,
601 git_version: "git version".to_string(),
602 },
603 }
604 .write(&state_dir.join("checkpoints/cp_head.meta"))
605 .expect("write session head checkpoint");
606
607 let output = render_log(&store).expect("render log");
608 assert!(output.contains("Recent Sessions"));
609 assert!(output.contains("Claude session"));
610 assert!(output.contains("Status: Active"));
611 assert!(output.contains("Recovery points:"));
612 assert!(output.contains("Session Head"));
613 assert!(output.contains("Rewindable"));
614 assert!(output.contains("Edit src/main.rs"));
615 assert!(output.contains("Restore only"));
616 assert!(output.contains("Latest protected action: Edit src/main.rs"));
617
618 fs::remove_dir_all(repo_root).expect("cleanup temp repo");
619 }
620
621 #[test]
622 fn parse_arguments_accepts_rewind_and_rejects_removed_commands() {
623 match parse_arguments([
624 OsString::from("ddl"),
625 OsString::from("rewind"),
626 OsString::from("cp_test"),
627 ])
628 .expect("parse rewind")
629 {
630 CommandLine::Rewind { checkpoint } => assert_eq!(checkpoint, "cp_test"),
631 _ => panic!("expected rewind command"),
632 }
633
634 let error = parse_arguments([
635 OsString::from("ddl"),
636 OsString::from("resume"),
637 OsString::from("cp_test"),
638 ])
639 .expect_err("resume should be removed");
640 assert!(
641 error
642 .to_string()
643 .contains("`ddl resume` was removed; use `ddl rewind <checkpoint_id>`")
644 );
645
646 let error = parse_arguments([
647 OsString::from("ddl"),
648 OsString::from("fork"),
649 OsString::from("cp_test"),
650 ])
651 .expect_err("fork should be removed");
652 assert!(error.to_string().contains("unknown command `fork`"));
653 }
654
655 #[test]
656 fn parse_arguments_accepts_where() {
657 match parse_arguments([OsString::from("ddl"), OsString::from("where")])
658 .expect("parse where")
659 {
660 CommandLine::Where => {}
661 _ => panic!("expected where command"),
662 }
663 }
664
665 #[test]
666 fn parse_arguments_accepts_config_variants() {
667 match parse_arguments([OsString::from("ddl"), OsString::from("config")])
668 .expect("parse config")
669 {
670 CommandLine::Config { action } => assert_eq!(action, ConfigAction::Show),
671 _ => panic!("expected config show command"),
672 }
673
674 match parse_arguments([
675 OsString::from("ddl"),
676 OsString::from("config"),
677 OsString::from("path"),
678 ])
679 .expect("parse config path")
680 {
681 CommandLine::Config { action } => assert_eq!(action, ConfigAction::Path),
682 _ => panic!("expected config path command"),
683 }
684
685 match parse_arguments([
686 OsString::from("ddl"),
687 OsString::from("config"),
688 OsString::from("edit"),
689 ])
690 .expect("parse config edit")
691 {
692 CommandLine::Config { action } => assert_eq!(action, ConfigAction::Edit),
693 _ => panic!("expected config edit command"),
694 }
695 }
696
697 #[test]
698 fn render_where_surfaces_state_location() {
699 let repo_root = create_temp_repo("app-where");
700 let store = DaedalusStore::discover_from(&repo_root).expect("discover store");
701 store.init().expect("initialize store");
702
703 let output = render_where(&store).expect("render where");
704 assert!(output.contains("Repo root:"));
705 assert!(output.contains(&repo_root.display().to_string()));
706 assert!(output.contains("State id:"));
707 assert!(output.contains("State dir:"));
708 assert!(output.contains("Config:"));
709 assert!(output.contains("Metadata:"));
710
711 fs::remove_dir_all(repo_root).expect("cleanup temp repo");
712 }
713
714 #[test]
715 fn render_config_surfaces_config_contents() {
716 let repo_root = create_temp_repo("app-config");
717 let store = DaedalusStore::discover_from(&repo_root).expect("discover store");
718 store.init().expect("initialize store");
719
720 let output = render_config(&store).expect("render config");
721 assert!(output.contains("Repo root:"));
722 assert!(output.contains("State id:"));
723 assert!(output.contains("Config path:"));
724 assert!(output.contains("\"checkpointing\""));
725 assert!(output.contains("Bash(rm:*)"));
726 assert!(!output.contains("Bash(npm install:*)"));
727
728 fs::remove_dir_all(repo_root).expect("cleanup temp repo");
729 }
730
731 #[test]
732 fn init_success_mentions_config_commands() {
733 let repo_root = create_temp_repo("app-init-message");
734 let store = DaedalusStore::discover_from(&repo_root).expect("discover store");
735 store.init().expect("initialize store");
736
737 let output =
738 render_init_success(&store, InitOutcome::Initialized).expect("render init success");
739 assert!(output.contains("initialized daedalus state in"));
740 assert!(output.contains("`ddl config`"));
741 assert!(output.contains("`ddl config edit`"));
742 assert!(output.contains("config path:"));
743
744 fs::remove_dir_all(repo_root).expect("cleanup temp repo");
745 }
746
747 #[test]
748 fn init_success_mentions_existing_state_when_reinitialized() {
749 let repo_root = create_temp_repo("app-init-existing-message");
750 let store = DaedalusStore::discover_from(&repo_root).expect("discover store");
751 store.init().expect("initialize store");
752
753 let output = render_init_success(&store, InitOutcome::AlreadyInitialized)
754 .expect("render init success");
755 assert!(output.contains("daedalus state already initialized in"));
756 assert!(output.contains("config path:"));
757
758 fs::remove_dir_all(repo_root).expect("cleanup temp repo");
759 }
760
761 #[test]
762 fn editor_command_argv_requires_editor() {
763 let error = editor_command_argv(None, Path::new("/tmp/config.json"))
764 .expect_err("missing editor should fail");
765 assert!(error.to_string().contains("EDITOR is not set"));
766 }
767
768 #[test]
769 fn editor_command_argv_appends_config_path() {
770 let argv = editor_command_argv(Some("code --wait"), Path::new("/tmp/daedalus/config.json"))
771 .expect("build editor argv");
772 assert_eq!(argv, vec!["code", "--wait", "/tmp/daedalus/config.json"]);
773 }
774
775 fn create_temp_repo(name: &str) -> PathBuf {
776 let path = std::env::temp_dir().join(format!(
777 "ddl-app-test-{name}-{}",
778 std::time::SystemTime::now()
779 .duration_since(std::time::UNIX_EPOCH)
780 .unwrap_or_default()
781 .as_nanos()
782 ));
783 fs::create_dir_all(&path).expect("create temp repo");
784 Command::new("git")
785 .arg("init")
786 .arg(&path)
787 .output()
788 .expect("git init");
789 path
790 }
791}