Skip to main content

ralph/cli/
init.rs

1//! `ralph init` command: Clap types and handler.
2//!
3//! Responsibilities:
4//! - Parse CLI arguments for the init command.
5//! - Determine interactive vs non-interactive mode based on flags and TTY detection.
6//! - Delegate to the init command implementation.
7//!
8//! Not handled here:
9//! - Actual file creation logic (see `crate::commands::init`).
10//! - Interactive wizard implementation (see `crate::commands::init`).
11//!
12//! Invariants/assumptions:
13//! - `--interactive` and `--non-interactive` are mutually exclusive.
14//! - TTY detection requires both stdin and stdout to be TTYs for interactive mode.
15//! - `--interactive` fails fast if stdin/stdout are not usable TTYs.
16
17use anyhow::{Context, Result};
18use clap::Args;
19
20use crate::{commands::init as init_cmd, config};
21
22/// Determine if both stdin and stdout are TTYs (interactive terminal).
23///
24/// Both streams must be TTYs for interactive prompting to work correctly.
25fn is_tty() -> bool {
26    atty::is(atty::Stream::Stdin) && atty::is(atty::Stream::Stdout)
27}
28
29/// Resolve interactive mode based on explicit flags and TTY detection.
30///
31/// Behavior:
32/// - `--interactive` explicitly enables interactive mode; errors if no TTY.
33/// - `--non-interactive` explicitly disables interactive mode.
34/// - Auto-detects based on TTY when neither flag is provided.
35///
36/// Returns Ok(true) for interactive mode, Ok(false) for non-interactive.
37/// Returns Err if `--interactive` is requested without a usable TTY.
38fn resolve_interactive_mode(
39    explicit_interactive: bool,
40    explicit_non_interactive: bool,
41) -> Result<bool> {
42    match (explicit_interactive, explicit_non_interactive) {
43        (true, _) => {
44            // Explicit --interactive: require TTY
45            if is_tty() {
46                Ok(true)
47            } else {
48                anyhow::bail!(
49                    "Interactive mode requested (--interactive) but stdin/stdout is not a TTY. \
50                     Use --non-interactive for CI/piped environments."
51                )
52            }
53        }
54        (_, true) => {
55            // Explicit --non-interactive
56            Ok(false)
57        }
58        (false, false) => {
59            // Auto-detect: require both stdin and stdout TTY
60            Ok(is_tty())
61        }
62    }
63}
64
65pub fn handle_init(args: InitArgs, force_lock: bool) -> Result<()> {
66    let resolved = if args.check {
67        config::resolve_from_cwd()?
68    } else if args.trust_project_commands {
69        config::resolve_from_cwd_skipping_project_execution_trust()?
70    } else {
71        config::resolve_from_cwd()?
72    };
73
74    // Handle --check mode: verify README is current and exit
75    // This runs before interactive resolution so it works in non-TTY environments
76    if args.check {
77        let check_result = init_cmd::check_readme_current(&resolved)?;
78        match check_result {
79            init_cmd::ReadmeCheckResult::Current(version) => {
80                log::info!("readme: current (version {})", version);
81                return Ok(());
82            }
83            init_cmd::ReadmeCheckResult::Outdated {
84                current_version,
85                embedded_version,
86            } => {
87                log::warn!(
88                    "readme: outdated (current version {}, embedded version {})",
89                    current_version,
90                    embedded_version
91                );
92                log::info!("Run 'ralph init --update-readme' to update");
93                std::process::exit(1);
94            }
95            init_cmd::ReadmeCheckResult::Missing => {
96                log::warn!("readme: missing (would be created on normal init)");
97                std::process::exit(1);
98            }
99            init_cmd::ReadmeCheckResult::NotApplicable => {
100                log::info!("readme: not applicable (prompts don't reference README)");
101                return Ok(());
102            }
103        }
104    }
105
106    // Determine interactive mode: explicit flags override TTY detection
107    let interactive = resolve_interactive_mode(args.interactive, args.non_interactive)
108        .with_context(|| {
109            "Failed to determine interactive mode. \
110             Use --non-interactive for CI/piped environments."
111        })?;
112
113    let report = init_cmd::run_init(
114        &resolved,
115        init_cmd::InitOptions {
116            force: args.force,
117            force_lock,
118            interactive,
119            update_readme: args.update_readme,
120        },
121    )?;
122
123    if args.trust_project_commands {
124        config::initialize_repo_trust_file(&resolved.repo_root)?;
125    }
126
127    fn report_status(label: &str, status: init_cmd::FileInitStatus, path: &std::path::Path) {
128        match status {
129            init_cmd::FileInitStatus::Created => {
130                log::info!("{}: created ({})", label, path.display())
131            }
132            init_cmd::FileInitStatus::Valid => {
133                log::info!("{}: exists (valid) ({})", label, path.display())
134            }
135            init_cmd::FileInitStatus::Updated => {
136                log::info!("{}: updated ({})", label, path.display())
137            }
138        }
139    }
140
141    report_status("queue", report.queue_status, &report.queue_path);
142    report_status("done", report.done_status, &report.done_path);
143    if let Some((status, version_info)) = report.readme_status {
144        let readme_path = resolved.repo_root.join(".ralph/README.md");
145        match status {
146            init_cmd::FileInitStatus::Created => {
147                if let Some(version) = version_info {
148                    log::info!(
149                        "readme: created (version {}) ({})",
150                        version,
151                        readme_path.display()
152                    );
153                } else {
154                    log::info!("readme: created ({})", readme_path.display());
155                }
156            }
157            init_cmd::FileInitStatus::Valid => {
158                if let Some(version) = version_info {
159                    log::info!(
160                        "readme: exists (version {}) ({})",
161                        version,
162                        readme_path.display()
163                    );
164                } else {
165                    log::info!("readme: exists (valid) ({})", readme_path.display());
166                }
167            }
168            init_cmd::FileInitStatus::Updated => {
169                if let Some(version) = version_info {
170                    log::info!(
171                        "readme: updated (version {}) ({})",
172                        version,
173                        readme_path.display()
174                    );
175                } else {
176                    log::info!("readme: updated ({})", readme_path.display());
177                }
178            }
179        }
180    }
181    report_status("config", report.config_status, &report.config_path);
182    Ok(())
183}
184
185#[derive(Args)]
186#[command(
187    about = "Bootstrap Ralph files in the current repository",
188    after_long_help = "Examples:\n  ralph init\n  ralph init --force\n  ralph init --interactive\n  ralph init --non-interactive\n  ralph init --trust-project-commands\n  ralph init --update-readme\n  ralph init --check"
189)]
190pub struct InitArgs {
191    /// Overwrite existing files if they already exist.
192    #[arg(long)]
193    pub force: bool,
194
195    /// Run interactive onboarding wizard (requires stdin+stdout TTY).
196    #[arg(short, long)]
197    pub interactive: bool,
198
199    /// Skip interactive prompts even if running in a TTY.
200    #[arg(long, conflicts_with = "interactive")]
201    pub non_interactive: bool,
202
203    /// Update README if it exists (force overwrite with latest template).
204    #[arg(long)]
205    pub update_readme: bool,
206
207    /// Check if README is current and exit (exit 0 if current, 1 if outdated/missing).
208    #[arg(long)]
209    pub check: bool,
210
211    /// After initializing Ralph, create `.ralph/trust.jsonc` so execution-sensitive project settings apply.
212    #[arg(long = "trust-project-commands", visible_alias = "trust")]
213    pub trust_project_commands: bool,
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219
220    #[test]
221    fn resolve_interactive_mode_explicit_non_interactive() {
222        // --non-interactive should always return false
223        let result = resolve_interactive_mode(false, true);
224        assert!(result.is_ok());
225        assert!(!result.unwrap());
226    }
227
228    #[test]
229    fn resolve_interactive_mode_explicit_interactive_without_tty() {
230        // --interactive without TTY should fail
231        // In a test environment (non-TTY), this should return an error
232        let result = resolve_interactive_mode(true, false);
233        // In non-TTY test environment, this should fail
234        if !is_tty() {
235            assert!(result.is_err());
236        } else {
237            assert!(result.is_ok());
238            assert!(result.unwrap());
239        }
240    }
241
242    #[test]
243    fn resolve_interactive_mode_auto_detect() {
244        // Auto-detect should return false in non-TTY environment
245        let result = resolve_interactive_mode(false, false);
246        assert!(result.is_ok());
247        // In test environment (non-TTY), should be false
248        // In TTY environment, would be true
249        assert_eq!(result.unwrap(), is_tty());
250    }
251
252    #[test]
253    fn resolve_interactive_mode_explicit_interactive_wins_over_non_interactive() {
254        // If both are true (shouldn't happen due to clap conflicts, but test logic)
255        // --interactive takes precedence
256        let result = resolve_interactive_mode(true, true);
257        // In non-TTY test environment, this should fail
258        // In TTY environment, should succeed with true
259        if !is_tty() {
260            assert!(result.is_err());
261        } else {
262            assert!(result.is_ok());
263            assert!(result.unwrap());
264        }
265    }
266}