Skip to main content

things3_cloud/commands/
project.rs

1use crate::app::Cli;
2use crate::commands::Command;
3use crate::common::{
4    BOLD, DIM, GREEN, ICONS, colored, fmt_deadline, fmt_task_line, fmt_task_with_note,
5};
6use anyhow::Result;
7use clap::Args;
8use std::collections::BTreeMap;
9
10#[derive(Debug, Args)]
11#[command(about = "Show all tasks in a project")]
12pub struct ProjectArgs {
13    /// Project UUID (or unique UUID prefix)
14    pub project_id: String,
15    /// Show notes beneath each task
16    #[arg(long)]
17    pub detailed: bool,
18}
19
20impl Command for ProjectArgs {
21    fn run_with_ctx(
22        &self,
23        cli: &Cli,
24        out: &mut dyn std::io::Write,
25        ctx: &mut dyn crate::cmd_ctx::CmdCtx,
26    ) -> Result<()> {
27        let store = cli.load_store()?;
28        let today = ctx.today();
29        let (task_opt, err, ambiguous) = store.resolve_mark_identifier(&self.project_id);
30        let Some(project) = task_opt else {
31            eprintln!("{err}");
32            for match_task in ambiguous {
33                eprintln!("  {}", match_task.title);
34            }
35            return Ok(());
36        };
37
38        if !project.is_project() {
39            eprintln!("Not a project: {}", project.title);
40            return Ok(());
41        }
42
43        let children = store
44            .tasks(None, Some(false), None)
45            .into_iter()
46            .filter(|t| store.effective_project_uuid(t).as_ref() == Some(&project.uuid))
47            .collect::<Vec<_>>();
48
49        let headings = store
50            .tasks_by_uuid
51            .values()
52            .filter(|t| {
53                t.is_heading() && !t.trashed && t.project.as_ref() == Some(&project.uuid)
54            })
55            .cloned()
56            .map(|h| (h.uuid.clone(), h))
57            .collect::<BTreeMap<_, _>>();
58
59        let mut ungrouped = Vec::new();
60        let mut by_heading: BTreeMap<_, Vec<_>> = BTreeMap::new();
61        for t in children.clone() {
62            if let Some(heading_uuid) = &t.action_group
63                && headings.contains_key(heading_uuid)
64            {
65                by_heading.entry(heading_uuid.clone()).or_default().push(t);
66                continue;
67            }
68            ungrouped.push(t);
69        }
70
71        let mut sorted_heading_uuids = by_heading.keys().cloned().collect::<Vec<_>>();
72        sorted_heading_uuids.sort_by_key(|u| headings.get(u).map(|h| h.index).unwrap_or(0));
73        ungrouped.sort_by_key(|t| t.index);
74        for items in by_heading.values_mut() {
75            items.sort_by_key(|t| t.index);
76        }
77
78        let total = children.len();
79        let progress = store.project_progress(&project.uuid);
80        let done_count = progress.done;
81
82        let tags = if project.tags.is_empty() {
83            String::new()
84        } else {
85            let tag_names = project
86                .tags
87                .iter()
88                .map(|t| store.resolve_tag_title(t))
89                .collect::<Vec<_>>()
90                .join(", ");
91            colored(&format!(" [{tag_names}]"), &[DIM], cli.no_color)
92        };
93        writeln!(
94            out,
95            "{}{}{}",
96            colored(
97                &format!(
98                    "{} {}  ({}/{})",
99                    ICONS.project,
100                    project.title,
101                    done_count,
102                    done_count + total as i32
103                ),
104                &[BOLD, GREEN],
105                cli.no_color,
106            ),
107            fmt_deadline(project.deadline, &today, cli.no_color),
108            tags
109        )?;
110
111        if let Some(notes) = &project.notes {
112            let lines = notes.lines().collect::<Vec<_>>();
113            for note in lines.iter().take(lines.len().saturating_sub(1)) {
114                writeln!(
115                    out,
116                    "{} {}",
117                    colored("  │", &[DIM], cli.no_color),
118                    colored(note, &[DIM], cli.no_color)
119                )?;
120            }
121            if let Some(last) = lines.last() {
122                writeln!(
123                    out,
124                    "{} {}",
125                    colored("  └", &[DIM], cli.no_color),
126                    colored(last, &[DIM], cli.no_color)
127                )?;
128            }
129        }
130
131        let mut all_uuids = vec![project.uuid.clone()];
132        all_uuids.extend(children.iter().map(|t| t.uuid.clone()));
133        let id_prefix_len = store.unique_prefix_length(&all_uuids);
134
135        if children.is_empty() {
136            writeln!(out, "{}", colored("  No tasks.", &[DIM], cli.no_color))?;
137            return Ok(());
138        }
139
140        if !ungrouped.is_empty() {
141            writeln!(out)?;
142            for t in ungrouped {
143                let line =
144                    fmt_task_line(
145                        &t,
146                        &store,
147                        false,
148                        true,
149                        false,
150                        Some(id_prefix_len),
151                        &today,
152                        cli.no_color,
153                    );
154                writeln!(
155                    out,
156                    "{}",
157                    fmt_task_with_note(
158                        line,
159                        &t,
160                        "  ",
161                        Some(id_prefix_len),
162                        self.detailed,
163                        cli.no_color,
164                    )
165                )?;
166            }
167        }
168
169        for heading_uuid in sorted_heading_uuids {
170            if let Some(heading) = headings.get(&heading_uuid) {
171                writeln!(out)?;
172                writeln!(
173                    out,
174                    "{}",
175                    colored(&format!("  {}", heading.title), &[BOLD], cli.no_color)
176                )?;
177                if let Some(tasks) = by_heading.get(&heading_uuid) {
178                    for t in tasks {
179                        let line = fmt_task_line(
180                            t,
181                            &store,
182                            false,
183                            true,
184                            false,
185                            Some(id_prefix_len),
186                            &today,
187                            cli.no_color,
188                        );
189                        writeln!(
190                            out,
191                            "{}",
192                            fmt_task_with_note(
193                                line,
194                                t,
195                                "    ",
196                                Some(id_prefix_len),
197                                self.detailed,
198                                cli.no_color,
199                            )
200                        )?;
201                    }
202                }
203            }
204        }
205
206        Ok(())
207    }
208}