foundry_mcp/cli/commands/
update_spec.rs1use crate::cli::args::UpdateSpecArgs;
4use crate::core::{context_patch::ContextMatcher, filesystem, project, spec};
5use crate::types::responses::{
6 FileUpdateResult, FoundryResponse, UpdateSpecResponse, ValidationStatus,
7};
8use crate::types::spec::{ContextOperation, ContextPatch, SpecFileType};
9use anyhow::Result;
10
11pub async fn execute(args: UpdateSpecArgs) -> Result<FoundryResponse<UpdateSpecResponse>> {
12 validate_args(&args)?;
14
15 validate_project_exists(&args.project_name)?;
17
18 if !spec::spec_exists(&args.project_name, &args.spec_name)? {
20 return Err(anyhow::anyhow!(
21 "Spec '{}' not found in project '{}'. Use 'foundry load-project {}' to see available specs.",
22 args.spec_name,
23 args.project_name,
24 args.project_name
25 ));
26 }
27
28 let (results, total_files_updated) = match args.operation.to_lowercase().as_str() {
30 "context_patch" => execute_context_patch(&args).await?,
31 "replace" | "append" => execute_traditional_update(&args).await?,
32 _ => return Err(anyhow::anyhow!("Invalid operation: {}", args.operation)),
33 };
34
35 let response_data = UpdateSpecResponse {
36 project_name: args.project_name.clone(),
37 spec_name: args.spec_name.clone(),
38 files_updated: results,
39 total_files_updated,
40 };
41
42 Ok(FoundryResponse {
43 data: response_data,
44 next_steps: generate_next_steps(&args),
45 validation_status: ValidationStatus::Complete,
46 workflow_hints: generate_workflow_hints(&args),
47 })
48}
49
50fn validate_args(args: &UpdateSpecArgs) -> Result<()> {
52 if args.project_name.trim().is_empty() {
53 return Err(anyhow::anyhow!("Project name cannot be empty"));
54 }
55
56 if args.spec_name.trim().is_empty() {
57 return Err(anyhow::anyhow!("Spec name cannot be empty"));
58 }
59
60 if args.operation.trim().is_empty() {
62 return Err(anyhow::anyhow!(
63 "Operation is required. Must be 'replace', 'append', or 'context_patch'"
64 ));
65 }
66
67 match args.operation.to_lowercase().as_str() {
68 "replace" | "append" => validate_traditional_args(args)?,
69 "context_patch" => validate_context_patch_args(args)?,
70 _ => {
71 return Err(anyhow::anyhow!(
72 "Invalid operation '{}'. Must be 'replace', 'append', or 'context_patch'",
73 args.operation
74 ));
75 }
76 }
77
78 Ok(())
79}
80
81fn validate_project_exists(project_name: &str) -> Result<()> {
83 if !project::project_exists(project_name)? {
84 return Err(anyhow::anyhow!(
85 "Project '{}' not found. Use 'foundry list-projects' to see available projects.",
86 project_name
87 ));
88 }
89 Ok(())
90}
91
92#[derive(Debug)]
94struct FileUpdate {
95 file_type: SpecFileType,
96 file_type_str: String,
97 content: String,
98}
99
100fn build_update_list(args: &UpdateSpecArgs) -> Result<Vec<FileUpdate>> {
102 let mut updates = Vec::new();
103
104 if let Some(ref spec_content) = args.spec {
105 updates.push(FileUpdate {
106 file_type: SpecFileType::Spec,
107 file_type_str: "spec".to_string(),
108 content: spec_content.clone(),
109 });
110 }
111
112 if let Some(ref tasks_content) = args.tasks {
113 updates.push(FileUpdate {
114 file_type: SpecFileType::TaskList,
115 file_type_str: "tasks".to_string(),
116 content: tasks_content.clone(),
117 });
118 }
119
120 if let Some(ref notes_content) = args.notes {
121 updates.push(FileUpdate {
122 file_type: SpecFileType::Notes,
123 file_type_str: "notes".to_string(),
124 content: notes_content.clone(),
125 });
126 }
127
128 Ok(updates)
129}
130
131fn update_single_file(args: &UpdateSpecArgs, file_update: &FileUpdate) -> Result<FileUpdateResult> {
133 let file_path = get_file_path(&args.project_name, &args.spec_name, &file_update.file_type)?;
134
135 match perform_file_update(args, file_update) {
136 Ok(content_length) => Ok(FileUpdateResult {
137 file_type: file_update.file_type_str.clone(),
138 operation_performed: args.operation.clone(),
139 file_path: file_path.to_string_lossy().to_string(),
140 content_length,
141 success: true,
142 error_message: None,
143 lines_modified: None,
144 patch_type: None,
145 match_confidence: None,
146 }),
147 Err(error) => Ok(FileUpdateResult {
148 file_type: file_update.file_type_str.clone(),
149 operation_performed: args.operation.clone(),
150 file_path: file_path.to_string_lossy().to_string(),
151 content_length: 0,
152 success: false,
153 error_message: Some(error.to_string()),
154 lines_modified: None,
155 patch_type: None,
156 match_confidence: None,
157 }),
158 }
159}
160
161fn perform_file_update(args: &UpdateSpecArgs, file_update: &FileUpdate) -> Result<usize> {
163 let final_content = if args.operation.to_lowercase() == "append" {
164 let current_content =
165 get_current_content(&args.project_name, &args.spec_name, &file_update.file_type)?;
166 if current_content.trim().is_empty() {
167 file_update.content.clone()
168 } else {
169 format!("{}\n\n{}", current_content, file_update.content)
170 }
171 } else {
172 file_update.content.clone()
173 };
174
175 spec::update_spec_content(
176 &args.project_name,
177 &args.spec_name,
178 file_update.file_type.clone(),
179 &final_content,
180 )?;
181
182 Ok(final_content.len())
183}
184
185fn get_current_content(
187 project_name: &str,
188 spec_name: &str,
189 file_type: &SpecFileType,
190) -> Result<String> {
191 let file_path = get_file_path(project_name, spec_name, file_type)?;
192
193 filesystem::read_file(&file_path).or_else(|_| Ok(String::new())) }
195
196fn get_file_path(
198 project_name: &str,
199 spec_name: &str,
200 file_type: &SpecFileType,
201) -> Result<std::path::PathBuf> {
202 let spec_path = spec::get_spec_path(project_name, spec_name)?;
203
204 let filename = match file_type {
205 SpecFileType::Spec => "spec.md",
206 SpecFileType::Notes => "notes.md",
207 SpecFileType::TaskList => "task-list.md",
208 };
209
210 Ok(spec_path.join(filename))
211}
212
213fn generate_next_steps(args: &UpdateSpecArgs) -> Vec<String> {
215 let mut steps = vec![
216 format!(
217 "Successfully updated spec '{}' in project '{}' with {} operation",
218 args.spec_name, args.project_name, args.operation
219 ),
220 format!(
221 "Load updated spec: foundry load_spec {} {}",
222 args.project_name, args.spec_name
223 ),
224 "Use 'foundry get-foundry-help content-examples' for formatting guidance".to_string(),
225 ];
226
227 if args.operation.to_lowercase() == "append" {
229 steps.push("Content was appended to preserve existing data".to_string());
230 steps.push(format!(
231 "Continue iterating: foundry update-spec {} {} --operation append",
232 args.project_name, args.spec_name
233 ));
234 } else {
235 steps.push("Content was completely replaced".to_string());
236 steps.push(
237 "Use --operation append for future updates to preserve existing content".to_string(),
238 );
239 }
240
241 steps
242}
243
244fn generate_workflow_hints(args: &UpdateSpecArgs) -> Vec<String> {
246 let mut hints = vec![
247 "📋 DOCUMENT PURPOSE: Your updates serve as COMPLETE CONTEXT for future implementation".to_string(),
248 "🎯 CONTEXT TEST: Could someone with no prior knowledge implement using your updated documents?".to_string(),
249 format!("Operation: {} content across multiple files", args.operation),
250 ];
251
252 let mut file_hints = Vec::new();
254 if args.spec.is_some() {
255 file_hints.push("spec.md");
256 }
257 if args.tasks.is_some() {
258 file_hints.push("task-list.md");
259 }
260 if args.notes.is_some() {
261 file_hints.push("notes.md");
262 }
263
264 if !file_hints.is_empty() {
265 hints.push(format!("Updated files: {}", file_hints.join(", ")));
266 }
267
268 if args.operation.to_lowercase() == "append" {
270 hints.push("Content was appended to preserve existing data".to_string());
271 hints.push("Use append operations to iteratively build up specifications".to_string());
272 } else {
273 hints.push("Content was completely replaced".to_string());
274 hints.push("Use replace operations for major restructuring or rewrites".to_string());
275 }
276
277 hints.push("Load the spec to see all updated content".to_string());
279 hints.push("Use --operation append for iterative development".to_string());
280 hints.push("Use --operation replace for major changes".to_string());
281
282 hints
283}
284
285async fn execute_context_patch(args: &UpdateSpecArgs) -> Result<(Vec<FileUpdateResult>, usize)> {
287 let context_patch_json = args.context_patch.as_ref().ok_or_else(|| {
289 anyhow::anyhow!("context_patch parameter is required for context_patch operation")
290 })?;
291
292 let patch: ContextPatch = serde_json::from_str(context_patch_json)
293 .map_err(|e| anyhow::anyhow!("Invalid context patch JSON: {}", e))?;
294
295 let file_path = match patch.file_type {
297 SpecFileType::Spec => spec::get_spec_file_path(&args.project_name, &args.spec_name)?,
298 SpecFileType::TaskList => {
299 spec::get_task_list_file_path(&args.project_name, &args.spec_name)?
300 }
301 SpecFileType::Notes => spec::get_notes_file_path(&args.project_name, &args.spec_name)?,
302 };
303
304 let current_content = filesystem::read_file(&file_path)?;
306
307 let mut matcher = ContextMatcher::new(current_content);
309 let patch_result = matcher.apply_patch(&patch)?;
310
311 let mut results = Vec::new();
312
313 if patch_result.success {
314 filesystem::write_file_atomic(&file_path, matcher.get_content())?;
316
317 results.push(FileUpdateResult {
318 file_type: format!("{:?}", patch.file_type),
319 file_path: file_path.to_string_lossy().to_string(),
320 content_length: matcher.get_content().len(),
321 operation_performed: args.operation.clone(),
322 success: true,
323 error_message: None,
324 lines_modified: Some(patch_result.lines_modified),
325 patch_type: Some(patch_result.patch_type),
326 match_confidence: patch_result.match_confidence,
327 });
328 } else {
329 results.push(FileUpdateResult {
331 file_type: format!("{:?}", patch.file_type),
332 file_path: file_path.to_string_lossy().to_string(),
333 content_length: 0,
334 operation_performed: args.operation.clone(),
335 success: false,
336 error_message: patch_result
337 .error_message
338 .or_else(|| Some("Context patch failed".to_string())),
339 lines_modified: Some(0),
340 patch_type: Some(patch_result.patch_type),
341 match_confidence: None,
342 });
343 }
344
345 let total_files = results.len();
346 Ok((results, total_files))
347}
348
349async fn execute_traditional_update(
351 args: &UpdateSpecArgs,
352) -> Result<(Vec<FileUpdateResult>, usize)> {
353 let files_to_update = build_update_list(args)?;
355
356 let mut results = Vec::new();
358 for file_update in files_to_update {
359 let result = update_single_file(args, &file_update)?;
360 results.push(result);
361 }
362
363 let total_files_updated = results.len();
364 Ok((results, total_files_updated))
365}
366
367fn validate_traditional_args(args: &UpdateSpecArgs) -> Result<()> {
369 let has_spec = args.spec.as_ref().is_some_and(|s| !s.trim().is_empty());
371 let has_tasks = args.tasks.as_ref().is_some_and(|s| !s.trim().is_empty());
372 let has_notes = args.notes.as_ref().is_some_and(|s| !s.trim().is_empty());
373
374 if !has_spec && !has_tasks && !has_notes {
375 return Err(anyhow::anyhow!(
376 "At least one content parameter must be provided. Use --spec, --tasks, or --notes to specify content for the files you want to update."
377 ));
378 }
379
380 Ok(())
381}
382
383fn validate_context_patch_args(args: &UpdateSpecArgs) -> Result<()> {
385 let context_patch_json = args.context_patch.as_ref().ok_or_else(|| {
387 anyhow::anyhow!("context_patch parameter is required for context_patch operation")
388 })?;
389
390 if context_patch_json.trim().is_empty() {
391 return Err(anyhow::anyhow!("context_patch parameter cannot be empty"));
392 }
393
394 let patch: ContextPatch = serde_json::from_str(context_patch_json)
396 .map_err(|e| anyhow::anyhow!("Invalid context patch JSON: {}. Expected format: {{\"file_type\":\"spec|tasks|notes\",\"operation\":\"insert|replace|delete\",\"before_context\":[\"line1\"],\"after_context\":[\"line2\"],\"content\":\"new content\"}}", e))?;
397
398 if patch.before_context.is_empty() && patch.after_context.is_empty() {
400 return Err(anyhow::anyhow!(
401 "At least one of 'before_context' or 'after_context' must be provided in context patch"
402 ));
403 }
404
405 match patch.operation {
407 ContextOperation::Insert | ContextOperation::Replace => {
408 if patch.content.trim().is_empty() {
409 return Err(anyhow::anyhow!(
410 "'content' field cannot be empty for insert/replace operations"
411 ));
412 }
413 }
414 ContextOperation::Delete => {
415 }
417 }
418
419 if args.spec.is_some() || args.tasks.is_some() || args.notes.is_some() {
421 return Err(anyhow::anyhow!(
422 "Cannot use --spec, --tasks, or --notes parameters with --operation context_patch. Use context_patch JSON parameter instead."
423 ));
424 }
425
426 Ok(())
427}