Skip to main content

ralph/commands/context/
workflow.rs

1//! AGENTS.md command workflows.
2//!
3//! Responsibilities:
4//! - Implement init, update, and validate command behavior.
5//! - Coordinate detection, wizard prompting, rendering, merging, and persistence.
6//!
7//! Not handled here:
8//! - CLI parsing.
9//! - Low-level markdown section parsing details.
10
11use super::detect::{detect_project_type, detected_type_to_hint, hint_to_detected, is_tty};
12use super::markdown::parse_markdown_sections;
13use super::render::{generate_agents_md, generate_agents_md_with_hints};
14use super::types::{
15    ContextInitOptions, ContextUpdateOptions, ContextValidateOptions, FileInitStatus, InitReport,
16    UpdateReport, ValidateReport,
17};
18use super::wizard::ContextPrompter;
19use super::{merge, validate, wizard};
20use crate::config;
21use crate::fsutil;
22use anyhow::{Context, Result};
23use std::fs;
24
25pub fn run_context_init(
26    resolved: &config::Resolved,
27    opts: ContextInitOptions,
28) -> Result<InitReport> {
29    if !opts.interactive && opts.output_path.exists() && !opts.force {
30        let detected = opts
31            .project_type_hint
32            .map(hint_to_detected)
33            .unwrap_or_else(|| detect_project_type(&resolved.repo_root));
34        return Ok(InitReport {
35            status: FileInitStatus::Valid,
36            detected_project_type: detected,
37            output_path: opts.output_path,
38        });
39    }
40
41    let detected_type = opts
42        .project_type_hint
43        .map(hint_to_detected)
44        .unwrap_or_else(|| detect_project_type(&resolved.repo_root));
45
46    let (project_type, output_path, content) = if opts.interactive {
47        if !is_tty() {
48            anyhow::bail!("Interactive mode requires a TTY terminal");
49        }
50
51        let prompter = wizard::DialoguerPrompter;
52        let wizard_result = wizard::run_init_wizard(
53            &prompter,
54            detected_type_to_hint(detected_type),
55            &opts.output_path,
56        )
57        .context("interactive wizard failed")?;
58
59        let project_type = hint_to_detected(wizard_result.project_type);
60        let output_path = wizard_result
61            .output_path
62            .unwrap_or_else(|| opts.output_path.clone());
63        let content = generate_agents_md_with_hints(
64            resolved,
65            project_type,
66            Some(&wizard_result.config_hints),
67        )?;
68
69        if wizard_result.confirm_write {
70            println!("\n{}", "─".repeat(60));
71            println!(
72                "{}",
73                colored::Colorize::bold("Preview of generated AGENTS.md:")
74            );
75            println!("{}", "─".repeat(60));
76            println!("{}", content);
77            println!("{}", "─".repeat(60));
78
79            let proceed = prompter
80                .confirm("Write this AGENTS.md?", true)
81                .context("failed to get confirmation")?;
82
83            if !proceed {
84                anyhow::bail!("AGENTS.md creation cancelled by user");
85            }
86        }
87
88        (project_type, output_path, content)
89    } else {
90        let content = generate_agents_md(resolved, detected_type)?;
91        (detected_type, opts.output_path.clone(), content)
92    };
93
94    if let Some(parent) = output_path.parent() {
95        fs::create_dir_all(parent)
96            .with_context(|| format!("create directory {}", parent.display()))?;
97    }
98    fsutil::write_atomic(&output_path, content.as_bytes())
99        .with_context(|| format!("write AGENTS.md {}", output_path.display()))?;
100
101    Ok(InitReport {
102        status: FileInitStatus::Created,
103        detected_project_type: project_type,
104        output_path,
105    })
106}
107
108pub fn run_context_update(
109    _resolved: &config::Resolved,
110    opts: ContextUpdateOptions,
111) -> Result<UpdateReport> {
112    if !opts.output_path.exists() {
113        anyhow::bail!(
114            "AGENTS.md does not exist at {}. Run `ralph context init` first.",
115            opts.output_path.display()
116        );
117    }
118
119    let existing_content =
120        fs::read_to_string(&opts.output_path).context("read existing AGENTS.md")?;
121    let existing_doc = merge::parse_markdown_document(&existing_content);
122    let existing_sections = existing_doc
123        .section_titles()
124        .into_iter()
125        .map(String::from)
126        .collect::<Vec<_>>();
127
128    let mut updates: Vec<(String, String)> = Vec::new();
129
130    if opts.interactive {
131        if !is_tty() {
132            anyhow::bail!("Interactive mode requires a TTY terminal");
133        }
134
135        let prompter = wizard::DialoguerPrompter;
136        updates = wizard::run_update_wizard(&prompter, &existing_sections, &existing_content)
137            .context("interactive wizard failed")?;
138    } else if let Some(file_path) = &opts.file {
139        let new_content = fs::read_to_string(file_path).context("read update file")?;
140        let parsed = parse_markdown_sections(&new_content);
141
142        for (section_name, section_content) in parsed {
143            if opts.sections.is_empty() || opts.sections.contains(&section_name) {
144                updates.push((section_name, section_content));
145            }
146        }
147    } else {
148        anyhow::bail!(
149            "No update source specified. Use --interactive, --file, or specify sections with content."
150        );
151    }
152
153    if updates.is_empty() {
154        return Ok(UpdateReport {
155            sections_updated: Vec::new(),
156            dry_run: opts.dry_run,
157        });
158    }
159
160    let (merged_doc, sections_updated) = merge::merge_section_updates(&existing_doc, &updates);
161
162    if opts.dry_run {
163        println!("\n{}", "─".repeat(60));
164        println!(
165            "{}",
166            colored::Colorize::bold("Dry run - changes that would be made:")
167        );
168        println!("{}", "─".repeat(60));
169        for section in &sections_updated {
170            println!("  • Update section: {}", section);
171        }
172        println!("{}", "─".repeat(60));
173        return Ok(UpdateReport {
174            sections_updated,
175            dry_run: true,
176        });
177    }
178
179    let merged_content = merged_doc.to_content();
180    fsutil::write_atomic(&opts.output_path, merged_content.as_bytes())
181        .with_context(|| format!("write AGENTS.md {}", opts.output_path.display()))?;
182
183    Ok(UpdateReport {
184        sections_updated,
185        dry_run: false,
186    })
187}
188
189pub fn run_context_validate(
190    _resolved: &config::Resolved,
191    opts: ContextValidateOptions,
192) -> Result<ValidateReport> {
193    validate::run_context_validate_impl(opts)
194}