1use std::{collections::BTreeMap, sync::Arc};
2
3use anyhow::Result;
4use clap::{Args, Subcommand};
5use iocraft::prelude::*;
6
7use crate::{
8 app::Cli,
9 commands::{Command, TagDeltaArgs, detailed_json_conflict, write_json},
10 common::{
11 DIM,
12 GREEN,
13 ICONS,
14 colored,
15 day_to_timestamp,
16 parse_day,
17 resolve_tag_ids,
18 task6_note,
19 },
20 ids::ThingsId,
21 ui::{
22 render_element_to_string,
23 views::{
24 json::common::build_tasks_json,
25 projects::{ProjectsAreaGroup, ProjectsView},
26 },
27 },
28 wire::{
29 notes::{StructuredTaskNotes, TaskNotes},
30 task::{TaskPatch, TaskProps, TaskStart, TaskStatus, TaskType},
31 wire_object::{EntityType, WireObject},
32 },
33};
34
35#[derive(Debug, Subcommand)]
36pub enum ProjectsSubcommand {
37 #[command(about = "Show all active projects")]
38 List(ProjectsListArgs),
39 #[command(about = "Create a new project")]
40 New(ProjectsNewArgs),
41 #[command(about = "Edit a project title, notes, area, or tags")]
42 Edit(ProjectsEditArgs),
43}
44
45#[derive(Debug, Args)]
46#[command(about = "Show, create, or edit projects")]
47pub struct ProjectsArgs {
48 #[arg(long, short = 'd')]
50 pub detailed: bool,
51 #[command(subcommand)]
52 pub command: Option<ProjectsSubcommand>,
53}
54
55#[derive(Debug, Default, Args)]
56pub struct ProjectsListArgs {
57 #[arg(long, short = 'd')]
59 pub detailed: bool,
60}
61
62#[derive(Debug, Args)]
63pub struct ProjectsNewArgs {
64 pub title: String,
66 #[arg(long, short = 'a', help = "Area UUID/prefix to place the project in")]
67 pub area: Option<String>,
68 #[arg(
69 long,
70 short = 'w',
71 help = "Schedule: anytime (default), someday, today, or YYYY-MM-DD"
72 )]
73 pub when: Option<String>,
74 #[arg(long, short = 'n', default_value = "", help = "Project notes")]
75 pub notes: String,
76 #[arg(
77 long,
78 short = 't',
79 help = "Comma-separated tags (titles or UUID prefixes)"
80 )]
81 pub tags: Option<String>,
82 #[arg(long = "deadline", short = 'd', help = "Deadline date (YYYY-MM-DD)")]
83 pub deadline_date: Option<String>,
84}
85
86#[derive(Debug, Args)]
87pub struct ProjectsEditArgs {
88 pub project_id: String,
90 #[arg(long, short = 't', help = "Replace title")]
91 pub title: Option<String>,
92 #[arg(long = "move", short = 'm', help = "Move to clear or area UUID/prefix")]
93 pub move_target: Option<String>,
94 #[arg(long, short = 'n', help = "Replace notes (use empty string to clear)")]
95 pub notes: Option<String>,
96 #[command(flatten)]
97 pub tag_delta: TagDeltaArgs,
98}
99
100#[derive(Debug, Clone)]
101struct ProjectsEditPlan {
102 project: crate::store::Task,
103 update: TaskPatch,
104 labels: Vec<String>,
105}
106
107fn build_projects_edit_plan(
108 args: &ProjectsEditArgs,
109 store: &crate::store::ThingsStore,
110 now: f64,
111) -> std::result::Result<ProjectsEditPlan, String> {
112 let (project_opt, err, _) = store.resolve_mark_identifier(&args.project_id);
113 let Some(project) = project_opt else {
114 return Err(err);
115 };
116 if !project.is_project() {
117 return Err("The specified ID is not a project.".to_string());
118 }
119
120 let mut update = TaskPatch::default();
121 let mut labels: Vec<String> = Vec::new();
122
123 if let Some(title) = &args.title {
124 let title = title.trim();
125 if title.is_empty() {
126 return Err("Project title cannot be empty.".to_string());
127 }
128 update.title = Some(title.to_string());
129 labels.push("title".to_string());
130 }
131
132 if let Some(notes) = &args.notes {
133 update.notes = Some(if notes.is_empty() {
134 TaskNotes::Structured(StructuredTaskNotes {
135 object_type: Some("tx".to_string()),
136 format_type: 1,
137 ch: Some(0),
138 v: Some(String::new()),
139 ps: Vec::new(),
140 unknown_fields: Default::default(),
141 })
142 } else {
143 task6_note(notes)
144 });
145 labels.push("notes".to_string());
146 }
147
148 if let Some(move_target) = &args.move_target {
149 let move_raw = move_target.trim();
150 let move_l = move_raw.to_lowercase();
151 if move_l == "inbox" {
152 return Err("Projects cannot be moved to Inbox.".to_string());
153 }
154 if move_l == "clear" {
155 update.area_ids = Some(vec![]);
156 labels.push("move=clear".to_string());
157 } else {
158 let (resolved_project, _, _) = store.resolve_mark_identifier(move_raw);
159 let (area, _, _) = store.resolve_area_identifier(move_raw);
160 let project_uuid = resolved_project.as_ref().and_then(|p| {
161 if p.is_project() {
162 Some(p.uuid.clone())
163 } else {
164 None
165 }
166 });
167 let area_uuid = area.as_ref().map(|a| a.uuid.clone());
168
169 if project_uuid.is_some() && area_uuid.is_some() {
170 return Err(format!(
171 "Ambiguous --move target '{}' (matches project and area).",
172 move_raw
173 ));
174 }
175 if project_uuid.is_some() {
176 return Err("Projects can only be moved to an area or clear.".to_string());
177 }
178 if let Some(area_uuid) = area_uuid {
179 let area_id = area_uuid;
180 update.area_ids = Some(vec![area_id]);
181 labels.push(format!("move={move_raw}"));
182 } else {
183 return Err(format!("Container not found: {move_raw}"));
184 }
185 }
186 }
187
188 let mut current_tags = project.tags.clone();
189 if let Some(add_tags) = &args.tag_delta.add_tags {
190 let (ids, err) = resolve_tag_ids(store, add_tags);
191 if !err.is_empty() {
192 return Err(err);
193 }
194 for id in ids {
195 if !current_tags.iter().any(|t| t == &id) {
196 current_tags.push(id);
197 }
198 }
199 labels.push("add-tags".to_string());
200 }
201 if let Some(remove_tags) = &args.tag_delta.remove_tags {
202 let (ids, err) = resolve_tag_ids(store, remove_tags);
203 if !err.is_empty() {
204 return Err(err);
205 }
206 current_tags.retain(|t| !ids.iter().any(|id| id == t));
207 labels.push("remove-tags".to_string());
208 }
209 if args.tag_delta.add_tags.is_some() || args.tag_delta.remove_tags.is_some() {
210 update.tag_ids = Some(current_tags);
211 }
212
213 if update.is_empty() {
214 return Err("No edit changes requested.".to_string());
215 }
216
217 update.modification_date = Some(now);
218
219 Ok(ProjectsEditPlan {
220 project,
221 update,
222 labels,
223 })
224}
225
226impl Command for ProjectsArgs {
227 fn run_with_ctx(
228 &self,
229 cli: &Cli,
230 out: &mut dyn std::io::Write,
231 ctx: &mut dyn crate::cmd_ctx::CmdCtx,
232 ) -> Result<()> {
233 let effective_detailed = match self.command.as_ref() {
238 None => self.detailed,
239 Some(ProjectsSubcommand::List(la)) => la.detailed,
240 _ => false,
241 };
242 let effective_json = match self.command.as_ref() {
243 None | Some(ProjectsSubcommand::List(_)) => cli.json,
244 _ => false,
245 };
246
247 match &self.command {
248 None | Some(ProjectsSubcommand::List(_)) => {
249 let store = Arc::new(cli.load_store()?);
250 let today = ctx.today();
251 let projects = store.projects(Some(TaskStatus::Incomplete));
252
253 if effective_json {
254 if detailed_json_conflict(effective_json, effective_detailed) {
255 return Ok(());
256 }
257 write_json(out, &build_tasks_json(&projects, &store, &today))?;
258 return Ok(());
259 }
260
261 let mut by_area: BTreeMap<Option<ThingsId>, Vec<_>> = BTreeMap::new();
262 for p in &projects {
263 by_area.entry(p.area.clone()).or_default().push(p.clone());
264 }
265
266 let mut id_scope = projects.iter().map(|p| p.uuid.clone()).collect::<Vec<_>>();
267 id_scope.extend(by_area.keys().flatten().cloned());
268 let id_prefix_len = store.unique_prefix_length(&id_scope);
269
270 let no_area = by_area.remove(&None).unwrap_or_default();
271
272 let mut area_entries: Vec<(ThingsId, Vec<_>)> = by_area
274 .into_iter()
275 .filter_map(|(k, v)| k.map(|uuid| (uuid, v)))
276 .collect();
277 area_entries.sort_by_key(|(uuid, _)| {
278 store
279 .areas_by_uuid
280 .get(uuid)
281 .map(|a| a.index)
282 .unwrap_or(i32::MAX)
283 });
284
285 let area_groups = area_entries
286 .into_iter()
287 .map(|(area_uuid, area_projects)| ProjectsAreaGroup {
288 area_title: store.resolve_area_title(&area_uuid),
289 area_uuid,
290 projects: area_projects,
291 })
292 .collect::<Vec<_>>();
293
294 let mut ui = element! {
295 ContextProvider(value: Context::owned(store.clone())) {
296 ContextProvider(value: Context::owned(today)) {
297 ProjectsView(
298 projects_count: projects.len(),
299 no_area_projects: no_area,
300 area_groups,
301 detailed: effective_detailed,
302 id_prefix_len,
303 )
304 }
305 }
306 };
307 let rendered = render_element_to_string(&mut ui, cli.no_color);
308 writeln!(out, "{}", rendered)?;
309 }
310 Some(ProjectsSubcommand::New(args)) => {
311 let title = args.title.trim();
312 if title.is_empty() {
313 eprintln!("Project title cannot be empty.");
314 return Ok(());
315 }
316
317 let store = cli.load_store()?;
318 let now = ctx.now_timestamp();
319 let mut props = TaskProps {
320 title: title.to_string(),
321 item_type: TaskType::Project,
322 status: TaskStatus::Incomplete,
323 start_location: TaskStart::Anytime,
324 instance_creation_paused: true,
325 creation_date: Some(now),
326 modification_date: Some(now),
327 ..Default::default()
328 };
329 if !args.notes.is_empty() {
330 props.notes = Some(task6_note(&args.notes));
331 }
332
333 if let Some(area_id) = &args.area {
334 let (area_opt, err, _) = store.resolve_area_identifier(area_id);
335 let Some(area) = area_opt else {
336 eprintln!("{err}");
337 return Ok(());
338 };
339 props.area_ids = vec![area.uuid.into()];
340 }
341
342 if let Some(when_raw) = &args.when {
343 let when = when_raw.trim().to_lowercase();
344 if when == "anytime" {
345 props.start_location = TaskStart::Anytime;
346 props.scheduled_date = None;
347 } else if when == "someday" {
348 props.start_location = TaskStart::Someday;
349 props.scheduled_date = None;
350 } else if when == "today" {
351 let ts = ctx.today_timestamp();
352 props.start_location = TaskStart::Anytime;
353 props.scheduled_date = Some(ts);
354 props.today_index_reference = Some(ts);
355 } else {
356 let day = match parse_day(Some(when_raw), "--when") {
357 Ok(Some(day)) => day,
358 Ok(None) => return Ok(()),
359 Err(e) => {
360 eprintln!("{e}");
361 return Ok(());
362 }
363 };
364 let ts = day_to_timestamp(day);
365 props.start_location = TaskStart::Someday;
366 props.scheduled_date = Some(ts);
367 props.today_index_reference = Some(ts);
368 }
369 }
370
371 if let Some(tags) = &args.tags {
372 let (tag_ids, err) = resolve_tag_ids(&store, tags);
373 if !err.is_empty() {
374 eprintln!("{err}");
375 return Ok(());
376 }
377 props.tag_ids = tag_ids;
378 }
379
380 if let Some(deadline) = &args.deadline_date {
381 let day = match parse_day(Some(deadline), "--deadline") {
382 Ok(Some(day)) => day,
383 Ok(None) => return Ok(()),
384 Err(e) => {
385 eprintln!("{e}");
386 return Ok(());
387 }
388 };
389 props.deadline = Some(day_to_timestamp(day) as i64);
390 }
391
392 let uuid = ctx.next_id();
393
394 let mut changes = BTreeMap::new();
395 changes.insert(uuid.clone(), WireObject::create(EntityType::Task6, props));
396 if let Err(e) = ctx.commit_changes(changes, None) {
397 eprintln!("Failed to create project: {e}");
398 return Ok(());
399 }
400
401 writeln!(
402 out,
403 "{} {} {}",
404 colored(&format!("{} Created", ICONS.done), &[GREEN], cli.no_color),
405 title,
406 colored(&uuid, &[DIM], cli.no_color)
407 )?;
408 }
409 Some(ProjectsSubcommand::Edit(args)) => {
410 let store = cli.load_store()?;
411 let plan = match build_projects_edit_plan(args, &store, ctx.now_timestamp()) {
412 Ok(plan) => plan,
413 Err(err) => {
414 eprintln!("{err}");
415 return Ok(());
416 }
417 };
418
419 let mut changes = BTreeMap::new();
420 changes.insert(
421 plan.project.uuid.to_string(),
422 WireObject::update(
423 EntityType::from(plan.project.entity.clone()),
424 plan.update.clone(),
425 ),
426 );
427 if let Err(e) = ctx.commit_changes(changes, None) {
428 eprintln!("Failed to edit project: {e}");
429 return Ok(());
430 }
431
432 let title = plan.update.title.as_deref().unwrap_or(&plan.project.title);
433 writeln!(
434 out,
435 "{} {} {} {}",
436 colored(&format!("{} Edited", ICONS.done), &[GREEN], cli.no_color),
437 title,
438 colored(&plan.project.uuid, &[DIM], cli.no_color),
439 colored(
440 &format!("({})", plan.labels.join(", ")),
441 &[DIM],
442 cli.no_color
443 )
444 )?;
445 }
446 }
447 Ok(())
448 }
449}
450
451#[cfg(test)]
452mod tests {
453 use serde_json::json;
454
455 use super::*;
456 use crate::{
457 ids::ThingsId,
458 store::{ThingsStore, fold_items},
459 wire::{
460 area::AreaProps,
461 tags::TagProps,
462 task::{TaskProps, TaskStart, TaskStatus, TaskType},
463 wire_object::{EntityType, WireItem, WireObject},
464 },
465 };
466
467 const NOW: f64 = 1_700_000_222.0;
468 const PROJECT_UUID: &str = "KGvAPpMrzHAKMdgMiERP1V";
469
470 fn build_store(entries: Vec<(String, WireObject)>) -> ThingsStore {
471 let mut item: WireItem = BTreeMap::new();
472 for (uuid, obj) in entries {
473 item.insert(uuid, obj);
474 }
475 ThingsStore::from_raw_state(&fold_items([item]))
476 }
477
478 fn project(uuid: &str, title: &str, tags: Vec<&str>) -> (String, WireObject) {
479 (
480 uuid.to_string(),
481 WireObject::create(
482 EntityType::Task6,
483 TaskProps {
484 title: title.to_string(),
485 item_type: TaskType::Project,
486 status: TaskStatus::Incomplete,
487 start_location: TaskStart::Anytime,
488 sort_index: 0,
489 tag_ids: tags
490 .iter()
491 .map(|t| {
492 t.parse::<ThingsId>()
493 .expect("test tag id should parse as ThingsId")
494 })
495 .collect(),
496 creation_date: Some(1.0),
497 modification_date: Some(1.0),
498 ..Default::default()
499 },
500 ),
501 )
502 }
503
504 fn area(uuid: &str, title: &str) -> (String, WireObject) {
505 (
506 uuid.to_string(),
507 WireObject::create(
508 EntityType::Area3,
509 AreaProps {
510 title: title.to_string(),
511 sort_index: 0,
512 ..Default::default()
513 },
514 ),
515 )
516 }
517
518 fn tag(uuid: &str, title: &str) -> (String, WireObject) {
519 (
520 uuid.to_string(),
521 WireObject::create(
522 EntityType::Tag4,
523 TagProps {
524 title: title.to_string(),
525 sort_index: 0,
526 ..Default::default()
527 },
528 ),
529 )
530 }
531
532 #[test]
533 fn projects_edit_payload_variants() {
534 let target_area_uuid = "JFdhhhp37fpryAKu8UXwzK";
535 let store = build_store(vec![
536 project(PROJECT_UUID, "Roadmap", vec![]),
537 area(target_area_uuid, "Personal"),
538 ]);
539
540 let title_plan = build_projects_edit_plan(
541 &ProjectsEditArgs {
542 project_id: PROJECT_UUID.to_string(),
543 title: Some("Roadmap v2".to_string()),
544 move_target: None,
545 notes: None,
546 tag_delta: TagDeltaArgs {
547 add_tags: None,
548 remove_tags: None,
549 },
550 },
551 &store,
552 NOW,
553 )
554 .expect("title plan");
555 let p = title_plan.update.into_properties();
556 assert_eq!(p.get("tt"), Some(&json!("Roadmap v2")));
557 assert_eq!(p.get("md"), Some(&json!(NOW)));
558
559 let clear_plan = build_projects_edit_plan(
560 &ProjectsEditArgs {
561 project_id: PROJECT_UUID.to_string(),
562 title: None,
563 move_target: Some("clear".to_string()),
564 notes: None,
565 tag_delta: TagDeltaArgs {
566 add_tags: None,
567 remove_tags: None,
568 },
569 },
570 &store,
571 NOW,
572 )
573 .expect("clear plan");
574 assert_eq!(
575 clear_plan.update.into_properties().get("ar"),
576 Some(&json!([]))
577 );
578
579 let move_plan = build_projects_edit_plan(
580 &ProjectsEditArgs {
581 project_id: PROJECT_UUID.to_string(),
582 title: None,
583 move_target: Some(target_area_uuid.to_string()),
584 notes: None,
585 tag_delta: TagDeltaArgs {
586 add_tags: None,
587 remove_tags: None,
588 },
589 },
590 &store,
591 NOW,
592 )
593 .expect("move area plan");
594 assert_eq!(
595 move_plan.update.into_properties().get("ar"),
596 Some(&json!([target_area_uuid]))
597 );
598 }
599
600 #[test]
601 fn projects_edit_tags_and_errors() {
602 let tag1 = "WukwpDdL5Z88nX3okGMKTC";
603 let tag2 = "JiqwiDaS3CAyjCmHihBDnB";
604 let store = build_store(vec![
605 project(PROJECT_UUID, "Roadmap", vec![tag1, tag2]),
606 tag(tag1, "Work"),
607 tag(tag2, "Focus"),
608 ]);
609
610 let remove_plan = build_projects_edit_plan(
611 &ProjectsEditArgs {
612 project_id: PROJECT_UUID.to_string(),
613 title: None,
614 move_target: None,
615 notes: None,
616 tag_delta: TagDeltaArgs {
617 add_tags: None,
618 remove_tags: Some("Work".to_string()),
619 },
620 },
621 &store,
622 NOW,
623 )
624 .expect("remove tags");
625 assert_eq!(
626 remove_plan.update.into_properties().get("tg"),
627 Some(&json!([tag2]))
628 );
629
630 let no_change = build_projects_edit_plan(
631 &ProjectsEditArgs {
632 project_id: PROJECT_UUID.to_string(),
633 title: None,
634 move_target: None,
635 notes: None,
636 tag_delta: TagDeltaArgs {
637 add_tags: None,
638 remove_tags: None,
639 },
640 },
641 &store,
642 NOW,
643 )
644 .expect_err("no changes");
645 assert_eq!(no_change, "No edit changes requested.");
646
647 let inbox = build_projects_edit_plan(
648 &ProjectsEditArgs {
649 project_id: PROJECT_UUID.to_string(),
650 title: None,
651 move_target: Some("inbox".to_string()),
652 notes: None,
653 tag_delta: TagDeltaArgs {
654 add_tags: None,
655 remove_tags: None,
656 },
657 },
658 &store,
659 NOW,
660 )
661 .expect_err("cannot move inbox");
662 assert_eq!(inbox, "Projects cannot be moved to Inbox.");
663 }
664}