sift_queue/cli/commands/
edit.rs1use 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
23pub 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 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 if let Some(ref title) = args.set_title {
72 attrs.title = Some(title.clone());
73 has_changes = true;
74 }
75
76 if let Some(ref description) = args.set_description {
78 attrs.description = Some(description.clone());
79 has_changes = true;
80 }
81
82 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 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 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 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 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 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 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 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}