1use crate::app::Cli;
2use crate::commands::{Command, TagDeltaArgs};
3use crate::common::{colored, id_prefix, resolve_tag_ids, BOLD, DIM, GREEN, ICONS, MAGENTA};
4use crate::wire::area::{AreaPatch, AreaProps};
5use crate::wire::wire_object::{EntityType, WireObject};
6use anyhow::Result;
7use clap::{Args, Subcommand};
8use serde_json::json;
9use std::collections::BTreeMap;
10
11#[derive(Debug, Subcommand)]
12pub enum AreasSubcommand {
13 #[command(about = "Show all areas")]
14 List(AreasListArgs),
15 #[command(about = "Create a new area")]
16 New(AreasNewArgs),
17 #[command(about = "Edit an area title or tags")]
18 Edit(AreasEditArgs),
19}
20
21#[derive(Debug, Args)]
22#[command(about = "Show or create areas")]
23pub struct AreasArgs {
24 #[command(subcommand)]
25 pub command: Option<AreasSubcommand>,
26}
27
28#[derive(Debug, Default, Args)]
29pub struct AreasListArgs {}
30
31#[derive(Debug, Args)]
32pub struct AreasNewArgs {
33 pub title: String,
35 #[arg(long, help = "Comma-separated tags (titles or UUID prefixes)")]
36 pub tags: Option<String>,
37}
38
39#[derive(Debug, Args)]
40pub struct AreasEditArgs {
41 pub area_id: String,
43 #[arg(long, help = "Replace title")]
44 pub title: Option<String>,
45 #[command(flatten)]
46 pub tag_delta: TagDeltaArgs,
47}
48
49#[derive(Debug, Clone)]
50struct AreasEditPlan {
51 area: crate::store::Area,
52 update: AreaPatch,
53 labels: Vec<String>,
54}
55
56fn build_areas_edit_plan(
57 args: &AreasEditArgs,
58 store: &crate::store::ThingsStore,
59 now: f64,
60) -> std::result::Result<AreasEditPlan, String> {
61 let (area_opt, err, _) = store.resolve_area_identifier(&args.area_id);
62 let Some(area) = area_opt else {
63 return Err(err);
64 };
65
66 let mut update = AreaPatch::default();
67 let mut labels = Vec::new();
68
69 if let Some(title) = &args.title {
70 let title = title.trim();
71 if title.is_empty() {
72 return Err("Area title cannot be empty.".to_string());
73 }
74 update.title = Some(title.to_string());
75 labels.push("title".to_string());
76 }
77
78 let mut current_tags = area.tags.clone();
79 if let Some(add_tags) = &args.tag_delta.add_tags {
80 let (ids, err) = resolve_tag_ids(store, add_tags);
81 if !err.is_empty() {
82 return Err(err);
83 }
84 for id in ids {
85 if !current_tags.iter().any(|t| t == &id) {
86 current_tags.push(id);
87 }
88 }
89 labels.push("add-tags".to_string());
90 }
91 if let Some(remove_tags) = &args.tag_delta.remove_tags {
92 let (ids, err) = resolve_tag_ids(store, remove_tags);
93 if !err.is_empty() {
94 return Err(err);
95 }
96 current_tags.retain(|t| !ids.iter().any(|id| id == t));
97 labels.push("remove-tags".to_string());
98 }
99 if args.tag_delta.add_tags.is_some() || args.tag_delta.remove_tags.is_some() {
100 update.tag_ids = Some(current_tags);
101 }
102
103 if update.is_empty() {
104 return Err("No edit changes requested.".to_string());
105 }
106
107 update.modification_date = Some(now);
108
109 Ok(AreasEditPlan {
110 area,
111 update,
112 labels,
113 })
114}
115
116impl Command for AreasArgs {
117 fn run_with_ctx(
118 &self,
119 cli: &Cli,
120 out: &mut dyn std::io::Write,
121 ctx: &mut dyn crate::cmd_ctx::CmdCtx,
122 ) -> Result<()> {
123 match self
124 .command
125 .as_ref()
126 .unwrap_or(&AreasSubcommand::List(AreasListArgs::default()))
127 {
128 AreasSubcommand::List(_) => {
129 let store = cli.load_store()?;
130 let areas = store.areas();
131 if areas.is_empty() {
132 writeln!(out, "{}", colored("No areas.", &[DIM], cli.no_color))?;
133 return Ok(());
134 }
135
136 writeln!(
137 out,
138 "{}",
139 colored(
140 &format!("{} Areas ({})", ICONS.area, areas.len()),
141 &[BOLD, MAGENTA],
142 cli.no_color,
143 )
144 )?;
145 writeln!(out)?;
146
147 let id_prefix_len = store.unique_prefix_length(
148 &areas.iter().map(|a| a.uuid.clone()).collect::<Vec<_>>(),
149 );
150 for area in areas {
151 let tags = if area.tags.is_empty() {
152 String::new()
153 } else {
154 let names = area
155 .tags
156 .iter()
157 .map(|t| store.resolve_tag_title(t))
158 .collect::<Vec<_>>()
159 .join(", ");
160 format!(" {}", colored(&format!("[{names}]"), &[DIM], cli.no_color))
161 };
162 writeln!(
163 out,
164 " {} {} {}{}",
165 id_prefix(&area.uuid, id_prefix_len, cli.no_color),
166 colored(ICONS.area, &[DIM], cli.no_color),
167 area.title,
168 tags
169 )?;
170 }
171 }
172 AreasSubcommand::New(args) => {
173 let title = args.title.trim();
174 if title.is_empty() {
175 eprintln!("Area title cannot be empty.");
176 return Ok(());
177 }
178
179 let store = cli.load_store()?;
180 let mut props = AreaProps {
181 title: title.to_string(),
182 sort_index: 0,
183 conflict_overrides: Some(json!({"_t":"oo","sn":{}})),
184 ..Default::default()
185 };
186
187 if let Some(tags) = &args.tags {
188 let (tag_ids, err) = resolve_tag_ids(&store, tags);
189 if !err.is_empty() {
190 eprintln!("{err}");
191 return Ok(());
192 }
193 props.tag_ids = tag_ids;
194 }
195
196 let uuid = ctx.next_id();
197 let mut changes = BTreeMap::new();
198 changes.insert(uuid.clone(), WireObject::create(EntityType::Area3, props));
199 if let Err(e) = ctx.commit_changes(changes, None) {
200 eprintln!("Failed to create area: {e}");
201 return Ok(());
202 }
203
204 writeln!(
205 out,
206 "{} {} {}",
207 colored(&format!("{} Created", ICONS.done), &[GREEN], cli.no_color),
208 title,
209 colored(&uuid, &[DIM], cli.no_color)
210 )?;
211 }
212 AreasSubcommand::Edit(args) => {
213 let store = cli.load_store()?;
214 let plan = match build_areas_edit_plan(args, &store, ctx.now_timestamp()) {
215 Ok(plan) => plan,
216 Err(err) => {
217 eprintln!("{err}");
218 return Ok(());
219 }
220 };
221
222 let mut changes = BTreeMap::new();
223 changes.insert(
224 plan.area.uuid.to_string(),
225 WireObject::update(EntityType::Area3, plan.update.clone()),
226 );
227 if let Err(e) = ctx.commit_changes(changes, None) {
228 eprintln!("Failed to edit area: {e}");
229 return Ok(());
230 }
231
232 let title = plan.update.title.as_deref().unwrap_or(&plan.area.title);
233 writeln!(
234 out,
235 "{} {} {} {}",
236 colored(&format!("{} Edited", ICONS.done), &[GREEN], cli.no_color),
237 title,
238 colored(&plan.area.uuid, &[DIM], cli.no_color),
239 colored(
240 &format!("({})", plan.labels.join(", ")),
241 &[DIM],
242 cli.no_color
243 )
244 )?;
245 }
246 }
247 Ok(())
248 }
249}
250
251#[cfg(test)]
252mod tests {
253 use super::*;
254 use crate::ids::ThingsId;
255 use crate::store::{fold_items, ThingsStore};
256 use crate::wire::area::AreaProps;
257 use crate::wire::tags::TagProps;
258 use crate::wire::wire_object::WireItem;
259 use crate::wire::wire_object::{EntityType, WireObject};
260
261 const NOW: f64 = 1_700_000_222.0;
262 const AREA_UUID: &str = "MpkEei6ybkFS2n6SXvwfLf";
263
264 fn build_store(entries: Vec<(String, WireObject)>) -> ThingsStore {
265 let mut item: WireItem = BTreeMap::new();
266 for (uuid, obj) in entries {
267 item.insert(uuid, obj);
268 }
269 ThingsStore::from_raw_state(&fold_items([item]))
270 }
271
272 fn area(uuid: &str, title: &str, tags: Vec<&str>) -> (String, WireObject) {
273 (
274 uuid.to_string(),
275 WireObject::create(
276 EntityType::Area3,
277 AreaProps {
278 title: title.to_string(),
279 tag_ids: tags.iter().map(|t| ThingsId::from(*t)).collect(),
280 sort_index: 0,
281 ..Default::default()
282 },
283 ),
284 )
285 }
286
287 fn tag(uuid: &str, title: &str) -> (String, WireObject) {
288 (
289 uuid.to_string(),
290 WireObject::create(
291 EntityType::Tag4,
292 TagProps {
293 title: title.to_string(),
294 sort_index: 0,
295 ..Default::default()
296 },
297 ),
298 )
299 }
300
301 #[test]
302 fn areas_edit_payload_and_errors() {
303 let tag1 = "WukwpDdL5Z88nX3okGMKTC";
304 let tag2 = "JiqwiDaS3CAyjCmHihBDnB";
305 let store = build_store(vec![
306 area(AREA_UUID, "Home", vec![tag1, tag2]),
307 tag(tag1, "Work"),
308 tag(tag2, "Focus"),
309 ]);
310
311 let title = build_areas_edit_plan(
312 &AreasEditArgs {
313 area_id: AREA_UUID.to_string(),
314 title: Some("New Name".to_string()),
315 tag_delta: TagDeltaArgs {
316 add_tags: None,
317 remove_tags: None,
318 },
319 },
320 &store,
321 NOW,
322 )
323 .expect("title plan");
324 let p = title.update.into_properties();
325 assert_eq!(p.get("tt"), Some(&json!("New Name")));
326 assert_eq!(p.get("md"), Some(&json!(NOW)));
327
328 let remove = build_areas_edit_plan(
329 &AreasEditArgs {
330 area_id: AREA_UUID.to_string(),
331 title: None,
332 tag_delta: TagDeltaArgs {
333 add_tags: None,
334 remove_tags: Some("Work".to_string()),
335 },
336 },
337 &store,
338 NOW,
339 )
340 .expect("remove tag");
341 assert_eq!(
342 remove.update.into_properties().get("tg"),
343 Some(&json!([tag2]))
344 );
345
346 let no_change = build_areas_edit_plan(
347 &AreasEditArgs {
348 area_id: AREA_UUID.to_string(),
349 title: None,
350 tag_delta: TagDeltaArgs {
351 add_tags: None,
352 remove_tags: None,
353 },
354 },
355 &store,
356 NOW,
357 )
358 .expect_err("no change");
359 assert_eq!(no_change, "No edit changes requested.");
360
361 let empty_title = build_areas_edit_plan(
362 &AreasEditArgs {
363 area_id: AREA_UUID.to_string(),
364 title: Some("".to_string()),
365 tag_delta: TagDeltaArgs {
366 add_tags: None,
367 remove_tags: None,
368 },
369 },
370 &store,
371 NOW,
372 )
373 .expect_err("empty title");
374 assert_eq!(empty_title, "Area title cannot be empty.");
375 }
376}