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 = config::resolve_from_cwd()?;
67
68    // Handle --check mode: verify README is current and exit
69    // This runs before interactive resolution so it works in non-TTY environments
70    if args.check {
71        let check_result = init_cmd::check_readme_current(&resolved)?;
72        match check_result {
73            init_cmd::ReadmeCheckResult::Current(version) => {
74                log::info!("readme: current (version {})", version);
75                return Ok(());
76            }
77            init_cmd::ReadmeCheckResult::Outdated {
78                current_version,
79                embedded_version,
80            } => {
81                log::warn!(
82                    "readme: outdated (current version {}, embedded version {})",
83                    current_version,
84                    embedded_version
85                );
86                log::info!("Run 'ralph init --update-readme' to update");
87                std::process::exit(1);
88            }
89            init_cmd::ReadmeCheckResult::Missing => {
90                log::warn!("readme: missing (would be created on normal init)");
91                std::process::exit(1);
92            }
93            init_cmd::ReadmeCheckResult::NotApplicable => {
94                log::info!("readme: not applicable (prompts don't reference README)");
95                return Ok(());
96            }
97        }
98    }
99
100    // Determine interactive mode: explicit flags override TTY detection
101    let interactive = resolve_interactive_mode(args.interactive, args.non_interactive)
102        .with_context(|| {
103            "Failed to determine interactive mode. \
104             Use --non-interactive for CI/piped environments."
105        })?;
106
107    let report = init_cmd::run_init(
108        &resolved,
109        init_cmd::InitOptions {
110            force: args.force,
111            force_lock,
112            interactive,
113            update_readme: args.update_readme,
114        },
115    )?;
116
117    fn report_status(label: &str, status: init_cmd::FileInitStatus, path: &std::path::Path) {
118        match status {
119            init_cmd::FileInitStatus::Created => {
120                log::info!("{}: created ({})", label, path.display())
121            }
122            init_cmd::FileInitStatus::Valid => {
123                log::info!("{}: exists (valid) ({})", label, path.display())
124            }
125            init_cmd::FileInitStatus::Updated => {
126                log::info!("{}: updated ({})", label, path.display())
127            }
128        }
129    }
130
131    report_status("queue", report.queue_status, &report.queue_path);
132    report_status("done", report.done_status, &report.done_path);
133    if let Some((status, version_info)) = report.readme_status {
134        let readme_path = resolved.repo_root.join(".ralph/README.md");
135        match status {
136            init_cmd::FileInitStatus::Created => {
137                if let Some(version) = version_info {
138                    log::info!(
139                        "readme: created (version {}) ({})",
140                        version,
141                        readme_path.display()
142                    );
143                } else {
144                    log::info!("readme: created ({})", readme_path.display());
145                }
146            }
147            init_cmd::FileInitStatus::Valid => {
148                if let Some(version) = version_info {
149                    log::info!(
150                        "readme: exists (version {}) ({})",
151                        version,
152                        readme_path.display()
153                    );
154                } else {
155                    log::info!("readme: exists (valid) ({})", readme_path.display());
156                }
157            }
158            init_cmd::FileInitStatus::Updated => {
159                if let Some(version) = version_info {
160                    log::info!(
161                        "readme: updated (version {}) ({})",
162                        version,
163                        readme_path.display()
164                    );
165                } else {
166                    log::info!("readme: updated ({})", readme_path.display());
167                }
168            }
169        }
170    }
171    report_status("config", report.config_status, &report.config_path);
172    Ok(())
173}
174
175#[derive(Args)]
176#[command(
177    about = "Bootstrap Ralph files in the current repository",
178    after_long_help = "Examples:\n  ralph init\n  ralph init --force\n  ralph init --interactive\n  ralph init --non-interactive\n  ralph init --update-readme\n  ralph init --check"
179)]
180pub struct InitArgs {
181    /// Overwrite existing files if they already exist.
182    #[arg(long)]
183    pub force: bool,
184
185    /// Run interactive onboarding wizard (requires stdin+stdout TTY).
186    #[arg(short, long)]
187    pub interactive: bool,
188
189    /// Skip interactive prompts even if running in a TTY.
190    #[arg(long, conflicts_with = "interactive")]
191    pub non_interactive: bool,
192
193    /// Update README if it exists (force overwrite with latest template).
194    #[arg(long)]
195    pub update_readme: bool,
196
197    /// Check if README is current and exit (exit 0 if current, 1 if outdated/missing).
198    #[arg(long)]
199    pub check: bool,
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205
206    #[test]
207    fn resolve_interactive_mode_explicit_non_interactive() {
208        // --non-interactive should always return false
209        let result = resolve_interactive_mode(false, true);
210        assert!(result.is_ok());
211        assert!(!result.unwrap());
212    }
213
214    #[test]
215    fn resolve_interactive_mode_explicit_interactive_without_tty() {
216        // --interactive without TTY should fail
217        // In a test environment (non-TTY), this should return an error
218        let result = resolve_interactive_mode(true, false);
219        // In non-TTY test environment, this should fail
220        if !is_tty() {
221            assert!(result.is_err());
222        } else {
223            assert!(result.is_ok());
224            assert!(result.unwrap());
225        }
226    }
227
228    #[test]
229    fn resolve_interactive_mode_auto_detect() {
230        // Auto-detect should return false in non-TTY environment
231        let result = resolve_interactive_mode(false, false);
232        assert!(result.is_ok());
233        // In test environment (non-TTY), should be false
234        // In TTY environment, would be true
235        assert_eq!(result.unwrap(), is_tty());
236    }
237
238    #[test]
239    fn resolve_interactive_mode_explicit_interactive_wins_over_non_interactive() {
240        // If both are true (shouldn't happen due to clap conflicts, but test logic)
241        // --interactive takes precedence
242        let result = resolve_interactive_mode(true, true);
243        // In non-TTY test environment, this should fail
244        // In TTY environment, should succeed with true
245        if !is_tty() {
246            assert!(result.is_err());
247        } else {
248            assert!(result.is_ok());
249            assert!(result.unwrap());
250        }
251    }
252}