Skip to main content

sift_queue/cli/commands/
edit.rs

1use crate::queue::{parse_priority_value, Queue, Source, UpdateAttrs, VALID_STATUSES};
2use crate::EditArgs;
3use anyhow::Result;
4use std::path::PathBuf;
5
6/// Execute the `sq edit` command.
7pub fn execute(args: &EditArgs, queue_path: PathBuf) -> Result<i32> {
8    let queue = Queue::new(queue_path);
9
10    let id = match &args.id {
11        Some(ref id) => id.as_str(),
12        None => {
13            eprintln!("Error: Item ID is required");
14            return Ok(1);
15        }
16    };
17
18    let item = match queue.find(id) {
19        Some(item) => item,
20        None => {
21            eprintln!("Error: Item not found: {}", id);
22            return Ok(1);
23        }
24    };
25
26    let mut attrs = UpdateAttrs::default();
27    let mut has_changes = false;
28
29    if args.set_metadata.is_some() && args.merge_metadata.is_some() {
30        eprintln!("Error: --set-metadata and --merge-metadata are mutually exclusive");
31        return Ok(1);
32    }
33
34    if args.set_priority.is_some() && args.clear_priority {
35        eprintln!("Error: --set-priority and --clear-priority are mutually exclusive");
36        return Ok(1);
37    }
38
39    // Status
40    if let Some(ref status) = args.set_status {
41        if !VALID_STATUSES.contains(&status.as_str()) {
42            eprintln!(
43                "Error: Invalid status: {}. Valid: {}",
44                status,
45                VALID_STATUSES.join(", ")
46            );
47            return Ok(1);
48        }
49        attrs.status = Some(status.clone());
50        has_changes = true;
51    }
52
53    // Title
54    if let Some(ref title) = args.set_title {
55        attrs.title = Some(title.clone());
56        has_changes = true;
57    }
58
59    // Description
60    if let Some(ref description) = args.set_description {
61        attrs.description = Some(description.clone());
62        has_changes = true;
63    }
64
65    // Priority
66    if let Some(ref priority_str) = args.set_priority {
67        match parse_priority_value(priority_str) {
68            Ok(priority) => {
69                attrs.priority = Some(Some(priority));
70                has_changes = true;
71            }
72            Err(err) => {
73                eprintln!("Error: {}", err);
74                return Ok(1);
75            }
76        }
77    }
78
79    if args.clear_priority {
80        attrs.priority = Some(None);
81        has_changes = true;
82    }
83
84    // Metadata (full replace)
85    if let Some(ref json_str) = args.set_metadata {
86        match serde_json::from_str::<serde_json::Value>(json_str) {
87            Ok(v) => {
88                attrs.metadata = Some(v);
89                has_changes = true;
90            }
91            Err(e) => {
92                eprintln!("Error: Invalid JSON for metadata: {}", e);
93                return Ok(1);
94            }
95        }
96    }
97
98    // Metadata (deep merge)
99    if let Some(ref json_str) = args.merge_metadata {
100        let patch = match serde_json::from_str::<serde_json::Value>(json_str) {
101            Ok(v) => v,
102            Err(e) => {
103                eprintln!("Error: Invalid JSON for merge metadata: {}", e);
104                return Ok(1);
105            }
106        };
107
108        if !patch.is_object() {
109            eprintln!("Error: --merge-metadata must be a JSON object");
110            return Ok(1);
111        }
112
113        let merged = deep_merge(item.metadata.clone(), patch);
114        attrs.metadata = Some(merged);
115        has_changes = true;
116    }
117
118    // Blocked by
119    if let Some(ref ids_str) = args.set_blocked_by {
120        let blocked_by: Vec<String> = ids_str
121            .split(',')
122            .map(|s: &str| s.trim().to_string())
123            .filter(|s: &String| !s.is_empty())
124            .collect();
125        attrs.blocked_by = Some(blocked_by);
126        has_changes = true;
127    }
128
129    // Source modifications
130    let has_source_adds = !args.add_diff.is_empty()
131        || !args.add_file.is_empty()
132        || !args.add_text.is_empty()
133        || !args.add_directory.is_empty()
134        || !args.add_transcript.is_empty();
135    let has_source_removes = !args.rm_source.is_empty();
136
137    if has_source_adds || has_source_removes {
138        has_changes = true;
139        let mut sources: Vec<serde_json::Value> =
140            item.sources.iter().map(|s| s.to_json_value()).collect();
141
142        // Remove sources (sort indices in reverse to preserve correctness)
143        let mut rm_indices: Vec<usize> = args.rm_source.clone();
144        rm_indices.sort();
145        rm_indices.reverse();
146        for index in rm_indices {
147            if index < sources.len() {
148                sources.remove(index);
149            } else {
150                eprintln!("Warning: Source index {} out of range", index);
151            }
152        }
153
154        // Add new sources
155        for path in &args.add_diff {
156            sources.push(source_value("diff", Some(path.as_str()), None));
157        }
158        for path in &args.add_file {
159            sources.push(source_value("file", Some(path.as_str()), None));
160        }
161        for text in &args.add_text {
162            sources.push(source_value("text", None, Some(text.as_str())));
163        }
164        for path in &args.add_directory {
165            sources.push(source_value("directory", Some(path.as_str()), None));
166        }
167        for path in &args.add_transcript {
168            sources.push(source_value("transcript", Some(path.as_str()), None));
169        }
170
171        if sources.is_empty() {
172            eprintln!("Error: Cannot remove all sources");
173            return Ok(1);
174        }
175
176        // Convert back to Source structs
177        let new_sources: Vec<Source> = sources
178            .into_iter()
179            .filter_map(|v| serde_json::from_value(v).ok())
180            .collect();
181        attrs.sources = Some(new_sources);
182    }
183
184    if !has_changes {
185        eprintln!("Error: No changes specified");
186        return Ok(1);
187    }
188
189    match queue.update(id, attrs)? {
190        Some(updated) => {
191            if args.json {
192                let json = serde_json::to_string_pretty(&updated.to_json_value())?;
193                println!("{}", json);
194            } else {
195                println!("{}", updated.id);
196                eprintln!("Updated item {}", updated.id);
197            }
198            Ok(0)
199        }
200        None => {
201            eprintln!("Error: Item not found: {}", id);
202            Ok(1)
203        }
204    }
205}
206
207fn deep_merge(current: serde_json::Value, patch: serde_json::Value) -> serde_json::Value {
208    match (current, patch) {
209        (serde_json::Value::Object(mut cur_map), serde_json::Value::Object(patch_map)) => {
210            for (k, patch_v) in patch_map {
211                let next = match cur_map.remove(&k) {
212                    Some(cur_v) => deep_merge(cur_v, patch_v),
213                    None => patch_v,
214                };
215                cur_map.insert(k, next);
216            }
217            serde_json::Value::Object(cur_map)
218        }
219        (_, patch_non_object) => patch_non_object,
220    }
221}
222
223fn source_value(type_: &str, path: Option<&str>, content: Option<&str>) -> serde_json::Value {
224    let mut map = serde_json::Map::new();
225    map.insert(
226        "type".to_string(),
227        serde_json::Value::String(type_.to_string()),
228    );
229    if let Some(p) = path {
230        map.insert("path".to_string(), serde_json::Value::String(p.to_string()));
231    }
232    if let Some(c) = content {
233        map.insert(
234            "content".to_string(),
235            serde_json::Value::String(c.to_string()),
236        );
237    }
238    serde_json::Value::Object(map)
239}