foundry_mcp/cli/commands/
update_spec.rs

1//! Implementation of the update_spec command
2
3use 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 inputs
13    validate_args(&args)?;
14
15    // Validate project exists
16    validate_project_exists(&args.project_name)?;
17
18    // Validate spec exists
19    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    // Branch based on operation type
29    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
50/// Validate command arguments
51fn 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    // Validate operation is required and valid
61    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
81/// Validate that project exists
82fn 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/// Represents a single file update operation
93#[derive(Debug)]
94struct FileUpdate {
95    file_type: SpecFileType,
96    file_type_str: String,
97    content: String,
98}
99
100/// Build list of files to update based on provided content arguments
101fn 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
131/// Process a single file update and return the result
132fn 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
161/// Perform the actual file update operation
162fn 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
185/// Get current content of a spec file for append operations
186fn 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())) // Return empty string if file doesn't exist or can't be read
194}
195
196/// Get the file path for a specific spec file type
197fn 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
213/// Generate next steps for the response
214fn 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    // Add operation-specific guidance
228    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
244/// Generate workflow hints for the response
245fn 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    // Add hints about which files were updated
253    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    // Add operation-specific guidance
269    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    // Add general guidance
278    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
285/// Execute context-based patch operation
286async fn execute_context_patch(args: &UpdateSpecArgs) -> Result<(Vec<FileUpdateResult>, usize)> {
287    // Parse the context patch JSON
288    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    // Determine which file to update based on the patch
296    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    // Read current file content
305    let current_content = filesystem::read_file(&file_path)?;
306
307    // Apply the context patch
308    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        // Write the updated content back to the file
315        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        // Patch failed - return error information
330        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
349/// Execute traditional update operation (replace/append)
350async fn execute_traditional_update(
351    args: &UpdateSpecArgs,
352) -> Result<(Vec<FileUpdateResult>, usize)> {
353    // Build list of files to update
354    let files_to_update = build_update_list(args)?;
355
356    // Process each file update
357    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
367/// Validate arguments for traditional operations (replace/append)
368fn validate_traditional_args(args: &UpdateSpecArgs) -> Result<()> {
369    // Validate at least one content parameter is provided
370    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
383/// Validate arguments for context patch operations
384fn validate_context_patch_args(args: &UpdateSpecArgs) -> Result<()> {
385    // Ensure context_patch parameter is provided
386    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    // Validate JSON structure
395    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    // Validate context requirements
399    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    // Validate content requirements based on operation
406    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            // Delete operations don't require content
416        }
417    }
418
419    // Ensure traditional content parameters are not provided with context_patch
420    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}