Skip to main content

ralph/cli/
context.rs

1//! `ralph context` command: Clap types and handler.
2//!
3//! Responsibilities:
4//! - Define CLI arguments for the `context` command group (init, update, validate).
5//! - Provide handler function that delegates to command implementations.
6//!
7//! Not handled here:
8//! - Actual file generation and manipulation (see `commands::context`).
9//! - Project type detection logic.
10//!
11//! Invariants/assumptions:
12//! - Output paths are resolved relative to the repository root.
13//! - Interactive mode requires a TTY.
14
15use anyhow::Result;
16use clap::{Args, Subcommand, ValueEnum};
17use std::path::PathBuf;
18
19use crate::commands::context as context_cmd;
20use crate::config;
21
22/// Handle the `context` command group.
23pub fn handle_context(args: ContextArgs) -> Result<()> {
24    let resolved = config::resolve_from_cwd()?;
25
26    match args.command {
27        ContextCommand::Init(init_args) => {
28            let report = context_cmd::run_context_init(
29                &resolved,
30                context_cmd::ContextInitOptions {
31                    force: init_args.force,
32                    project_type_hint: init_args.project_type,
33                    output_path: init_args
34                        .output
35                        .unwrap_or_else(|| resolved.repo_root.join("AGENTS.md")),
36                    interactive: init_args.interactive,
37                },
38            )?;
39
40            match report.status {
41                context_cmd::FileInitStatus::Created => {
42                    log::info!(
43                        "AGENTS.md created for {} project ({})",
44                        format!("{:?}", report.detected_project_type).to_lowercase(),
45                        report.output_path.display()
46                    );
47                }
48                context_cmd::FileInitStatus::Valid => {
49                    log::info!(
50                        "AGENTS.md already exists ({}). Use --force to overwrite.",
51                        report.output_path.display()
52                    );
53                }
54            }
55            Ok(())
56        }
57        ContextCommand::Update(update_args) => {
58            let report = context_cmd::run_context_update(
59                &resolved,
60                context_cmd::ContextUpdateOptions {
61                    sections: update_args.section,
62                    file: update_args.file,
63                    interactive: update_args.interactive,
64                    dry_run: update_args.dry_run,
65                    output_path: update_args
66                        .output
67                        .unwrap_or_else(|| resolved.repo_root.join("AGENTS.md")),
68                },
69            )?;
70
71            if report.dry_run {
72                log::info!("Dry run - no changes written");
73            } else {
74                log::info!(
75                    "AGENTS.md updated: {} sections modified",
76                    report.sections_updated.len()
77                );
78            }
79            Ok(())
80        }
81        ContextCommand::Validate(validate_args) => {
82            let report = context_cmd::run_context_validate(
83                &resolved,
84                context_cmd::ContextValidateOptions {
85                    strict: validate_args.strict,
86                    path: validate_args
87                        .path
88                        .unwrap_or_else(|| resolved.repo_root.join("AGENTS.md")),
89                },
90            )?;
91
92            if report.valid {
93                log::info!("AGENTS.md is valid and up to date");
94            } else {
95                log::warn!("AGENTS.md has issues:");
96                if !report.missing_sections.is_empty() {
97                    log::warn!("  Missing sections: {:?}", report.missing_sections);
98                }
99                if !report.outdated_sections.is_empty() {
100                    log::warn!("  Outdated sections: {:?}", report.outdated_sections);
101                }
102                anyhow::bail!("Validation failed");
103            }
104            Ok(())
105        }
106    }
107}
108
109#[derive(Args)]
110#[command(
111    about = "Manage project context (AGENTS.md) for AI agents",
112    after_long_help = "Examples:\n  ralph context init\n  ralph context init --project-type rust\n  ralph context update --section troubleshooting\n  ralph context validate\n  ralph context update --dry-run"
113)]
114pub struct ContextArgs {
115    #[command(subcommand)]
116    pub command: ContextCommand,
117}
118
119#[derive(Subcommand)]
120pub enum ContextCommand {
121    /// Generate initial AGENTS.md from project detection
122    #[command(
123        after_long_help = "Examples:\n  ralph context init\n  ralph context init --force\n  ralph context init --project-type python --output docs/AGENTS.md"
124    )]
125    Init(ContextInitArgs),
126
127    /// Update AGENTS.md with new learnings
128    #[command(
129        after_long_help = "Examples:\n  ralph context update --section troubleshooting\n  ralph context update --file new_learnings.md\n  ralph context update --interactive"
130    )]
131    Update(ContextUpdateArgs),
132
133    /// Validate AGENTS.md is up to date with project structure
134    #[command(
135        after_long_help = "Examples:\n  ralph context validate\n  ralph context validate --strict"
136    )]
137    Validate(ContextValidateArgs),
138}
139
140#[derive(Args)]
141pub struct ContextInitArgs {
142    /// Force overwrite if AGENTS.md already exists
143    #[arg(long)]
144    pub force: bool,
145
146    /// Project type override (auto-detect if not specified)
147    #[arg(long, value_enum)]
148    pub project_type: Option<ProjectTypeHint>,
149
150    /// Output path for AGENTS.md (default: AGENTS.md in repo root)
151    #[arg(long, short)]
152    pub output: Option<PathBuf>,
153
154    /// Interactive mode to guide through context creation
155    #[arg(long, short)]
156    pub interactive: bool,
157}
158
159#[derive(Args)]
160pub struct ContextUpdateArgs {
161    /// Section to update (can be specified multiple times)
162    #[arg(long, short)]
163    pub section: Vec<String>,
164
165    /// File containing new learnings to append
166    #[arg(long, short)]
167    pub file: Option<PathBuf>,
168
169    /// Interactive mode to select sections and input learnings
170    #[arg(long, short)]
171    pub interactive: bool,
172
173    /// Dry run - preview changes without writing
174    #[arg(long)]
175    pub dry_run: bool,
176
177    /// Output path (default: existing AGENTS.md location)
178    #[arg(long, short)]
179    pub output: Option<PathBuf>,
180}
181
182#[derive(Args)]
183pub struct ContextValidateArgs {
184    /// Strict mode - fail if any recommended sections are missing
185    #[arg(long)]
186    pub strict: bool,
187
188    /// Path to AGENTS.md (default: auto-discover)
189    #[arg(long, short)]
190    pub path: Option<PathBuf>,
191}
192
193#[derive(Clone, Copy, Debug, PartialEq, ValueEnum)]
194pub enum ProjectTypeHint {
195    Rust,
196    Python,
197    TypeScript,
198    Go,
199    Generic,
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205    use clap::Parser;
206
207    #[test]
208    fn cli_parses_context_init() {
209        let cli = crate::cli::Cli::try_parse_from(["ralph", "context", "init"]).expect("parse");
210        match cli.command {
211            crate::cli::Command::Context(args) => match args.command {
212                ContextCommand::Init(init_args) => {
213                    assert!(!init_args.force);
214                    assert!(init_args.project_type.is_none());
215                    assert!(init_args.output.is_none());
216                    assert!(!init_args.interactive);
217                }
218                _ => panic!("expected context init command"),
219            },
220            _ => panic!("expected context command"),
221        }
222    }
223
224    #[test]
225    fn cli_parses_context_init_with_force() {
226        let cli = crate::cli::Cli::try_parse_from(["ralph", "context", "init", "--force"])
227            .expect("parse");
228        match cli.command {
229            crate::cli::Command::Context(args) => match args.command {
230                ContextCommand::Init(init_args) => {
231                    assert!(init_args.force);
232                }
233                _ => panic!("expected context init command"),
234            },
235            _ => panic!("expected context command"),
236        }
237    }
238
239    #[test]
240    fn cli_parses_context_init_with_project_type() {
241        let cli =
242            crate::cli::Cli::try_parse_from(["ralph", "context", "init", "--project-type", "rust"])
243                .expect("parse");
244        match cli.command {
245            crate::cli::Command::Context(args) => match args.command {
246                ContextCommand::Init(init_args) => {
247                    assert_eq!(init_args.project_type, Some(ProjectTypeHint::Rust));
248                }
249                _ => panic!("expected context init command"),
250            },
251            _ => panic!("expected context command"),
252        }
253    }
254
255    #[test]
256    fn cli_parses_context_init_with_output() {
257        let cli = crate::cli::Cli::try_parse_from([
258            "ralph",
259            "context",
260            "init",
261            "--output",
262            "docs/AGENTS.md",
263        ])
264        .expect("parse");
265        match cli.command {
266            crate::cli::Command::Context(args) => match args.command {
267                ContextCommand::Init(init_args) => {
268                    assert_eq!(init_args.output, Some(PathBuf::from("docs/AGENTS.md")));
269                }
270                _ => panic!("expected context init command"),
271            },
272            _ => panic!("expected context command"),
273        }
274    }
275
276    #[test]
277    fn cli_parses_context_update_with_section() {
278        let cli = crate::cli::Cli::try_parse_from([
279            "ralph",
280            "context",
281            "update",
282            "--section",
283            "troubleshooting",
284        ])
285        .expect("parse");
286        match cli.command {
287            crate::cli::Command::Context(args) => match args.command {
288                ContextCommand::Update(update_args) => {
289                    assert_eq!(update_args.section, vec!["troubleshooting"]);
290                    assert!(!update_args.dry_run);
291                }
292                _ => panic!("expected context update command"),
293            },
294            _ => panic!("expected context command"),
295        }
296    }
297
298    #[test]
299    fn cli_parses_context_update_with_multiple_sections() {
300        let cli = crate::cli::Cli::try_parse_from([
301            "ralph",
302            "context",
303            "update",
304            "--section",
305            "troubleshooting",
306            "--section",
307            "git-hygiene",
308        ])
309        .expect("parse");
310        match cli.command {
311            crate::cli::Command::Context(args) => match args.command {
312                ContextCommand::Update(update_args) => {
313                    assert_eq!(update_args.section, vec!["troubleshooting", "git-hygiene"]);
314                }
315                _ => panic!("expected context update command"),
316            },
317            _ => panic!("expected context command"),
318        }
319    }
320
321    #[test]
322    fn cli_parses_context_update_with_dry_run() {
323        let cli = crate::cli::Cli::try_parse_from(["ralph", "context", "update", "--dry-run"])
324            .expect("parse");
325        match cli.command {
326            crate::cli::Command::Context(args) => match args.command {
327                ContextCommand::Update(update_args) => {
328                    assert!(update_args.dry_run);
329                }
330                _ => panic!("expected context update command"),
331            },
332            _ => panic!("expected context command"),
333        }
334    }
335
336    #[test]
337    fn cli_parses_context_validate() {
338        let cli = crate::cli::Cli::try_parse_from(["ralph", "context", "validate"]).expect("parse");
339        match cli.command {
340            crate::cli::Command::Context(args) => match args.command {
341                ContextCommand::Validate(validate_args) => {
342                    assert!(!validate_args.strict);
343                    assert!(validate_args.path.is_none());
344                }
345                _ => panic!("expected context validate command"),
346            },
347            _ => panic!("expected context command"),
348        }
349    }
350
351    #[test]
352    fn cli_parses_context_validate_with_strict() {
353        let cli = crate::cli::Cli::try_parse_from(["ralph", "context", "validate", "--strict"])
354            .expect("parse");
355        match cli.command {
356            crate::cli::Command::Context(args) => match args.command {
357                ContextCommand::Validate(validate_args) => {
358                    assert!(validate_args.strict);
359                }
360                _ => panic!("expected context validate command"),
361            },
362            _ => panic!("expected context command"),
363        }
364    }
365}