Skip to main content

ddl/
app.rs

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}