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