Skip to main content

things3_cloud/commands/
projects.rs

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    /// Show notes for each project.
29    #[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    /// Show notes for each task
38    #[arg(long)]
39    pub detailed: bool,
40}
41
42#[derive(Debug, Args)]
43pub struct ProjectsNewArgs {
44    /// Project title
45    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    /// Project UUID (or unique UUID prefix)
64    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        // Match Python argparse behavior:
209        // - `projects --detailed` (no subcommand) => detailed output
210        // - `projects list --detailed` => detailed output
211        // - `projects --detailed list` => not detailed (subcommand parser default wins)
212        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                // Sort areas by their index field so output order matches Python
274                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}