ralph/commands/context/
workflow.rs1use 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(§ion_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 §ions_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}