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