Skip to main content

sift_queue/cli/commands/
edit.rs

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