Skip to main content

chub_cli/commands/
annotate.rs

1use clap::Args;
2use owo_colors::OwoColorize;
3
4use chub_core::annotations::{
5    clear_annotation, list_annotations, read_annotation, write_annotation, AnnotationKind,
6};
7
8use crate::output;
9
10#[derive(Args)]
11pub struct AnnotateArgs {
12    /// Entry ID
13    id: Option<String>,
14
15    /// Annotation text
16    note: Option<String>,
17
18    /// Remove annotation (respects --team/--org flag)
19    #[arg(long)]
20    clear: bool,
21
22    /// List all annotations
23    #[arg(long)]
24    list: bool,
25
26    /// Save as team annotation (git-tracked in .chub/annotations/)
27    #[arg(long)]
28    team: bool,
29
30    /// Save as personal annotation only (default)
31    #[arg(long)]
32    personal: bool,
33
34    /// Write to org annotation server (Tier 3 — requires annotation_server in config)
35    #[arg(long)]
36    org: bool,
37
38    /// Author name for team/org annotations
39    #[arg(long)]
40    author: Option<String>,
41
42    /// Annotation kind: note (default), issue, fix, practice
43    #[arg(long, value_name = "KIND")]
44    kind: Option<String>,
45
46    /// Severity for issue annotations: high, medium, low
47    #[arg(long, value_name = "LEVEL")]
48    severity: Option<String>,
49}
50
51fn parse_kind(s: Option<&str>) -> AnnotationKind {
52    s.and_then(AnnotationKind::parse)
53        .unwrap_or(AnnotationKind::Note)
54}
55
56fn get_author(explicit: Option<&str>) -> String {
57    explicit.map(|s| s.to_string()).unwrap_or_else(|| {
58        std::env::var("USER")
59            .or_else(|_| std::env::var("USERNAME"))
60            .unwrap_or_else(|_| "unknown".to_string())
61    })
62}
63
64fn print_team_annotation_list(annotations: &[chub_core::team::team_annotations::TeamAnnotation]) {
65    for a in annotations {
66        eprintln!("{}", a.id.bold());
67        if !a.issues.is_empty() {
68            eprintln!("  {}", "Issues:".yellow());
69            for note in &a.issues {
70                let sev = note
71                    .severity
72                    .as_deref()
73                    .map(|s| format!(" [{}]", s))
74                    .unwrap_or_default();
75                eprintln!(
76                    "    {} {}{} {}",
77                    note.author.cyan(),
78                    format!("({})", note.date).dimmed(),
79                    sev.yellow(),
80                    note.note
81                );
82            }
83        }
84        if !a.fixes.is_empty() {
85            eprintln!("  {}", "Fixes:".green());
86            for note in &a.fixes {
87                eprintln!(
88                    "    {} {} {}",
89                    note.author.cyan(),
90                    format!("({})", note.date).dimmed(),
91                    note.note
92                );
93            }
94        }
95        if !a.practices.is_empty() {
96            eprintln!("  {}", "Practices:".blue());
97            for note in &a.practices {
98                eprintln!(
99                    "    {} {} {}",
100                    note.author.cyan(),
101                    format!("({})", note.date).dimmed(),
102                    note.note
103                );
104            }
105        }
106        if !a.notes.is_empty() {
107            eprintln!("  {}", "Notes:".dimmed());
108            for note in &a.notes {
109                eprintln!(
110                    "    {} {} {}",
111                    note.author.cyan(),
112                    format!("({})", note.date).dimmed(),
113                    note.note
114                );
115            }
116        }
117        eprintln!();
118    }
119}
120
121fn print_team_annotation_single(id: &str, ann: &chub_core::team::team_annotations::TeamAnnotation) {
122    eprintln!("{}", id.bold());
123    if !ann.issues.is_empty() {
124        eprintln!("  {}", "Issues:".yellow());
125        for note in &ann.issues {
126            let sev = note
127                .severity
128                .as_deref()
129                .map(|s| format!(" [{}]", s))
130                .unwrap_or_default();
131            eprintln!(
132                "    {} {}{} {}",
133                note.author.cyan(),
134                format!("({})", note.date).dimmed(),
135                sev.yellow(),
136                note.note
137            );
138        }
139    }
140    if !ann.fixes.is_empty() {
141        eprintln!("  {}", "Fixes:".green());
142        for note in &ann.fixes {
143            eprintln!(
144                "    {} {} {}",
145                note.author.cyan(),
146                format!("({})", note.date).dimmed(),
147                note.note
148            );
149        }
150    }
151    if !ann.practices.is_empty() {
152        eprintln!("  {}", "Practices:".blue());
153        for note in &ann.practices {
154            eprintln!(
155                "    {} {} {}",
156                note.author.cyan(),
157                format!("({})", note.date).dimmed(),
158                note.note
159            );
160        }
161    }
162    for note in &ann.notes {
163        eprintln!(
164            "  {} {} {}",
165            note.author.cyan(),
166            format!("({})", note.date).dimmed(),
167            note.note
168        );
169    }
170}
171
172pub async fn run(args: AnnotateArgs, json: bool) {
173    if args.list {
174        if args.org {
175            let annotations = chub_core::team::org_annotations::list_org_annotations().await;
176            if json {
177                println!(
178                    "{}",
179                    serde_json::to_string_pretty(&annotations).unwrap_or_default()
180                );
181            } else {
182                if annotations.is_empty() {
183                    eprintln!("No org annotations.");
184                    return;
185                }
186                print_team_annotation_list(&annotations);
187            }
188        } else if args.team {
189            // List team annotations
190            let annotations = chub_core::team::team_annotations::list_team_annotations();
191            if json {
192                println!(
193                    "{}",
194                    serde_json::to_string_pretty(&annotations).unwrap_or_default()
195                );
196            } else {
197                if annotations.is_empty() {
198                    eprintln!("No team annotations.");
199                    return;
200                }
201                print_team_annotation_list(&annotations);
202            }
203        } else {
204            let annotations = list_annotations();
205            if json {
206                println!(
207                    "{}",
208                    serde_json::to_string_pretty(&annotations).unwrap_or_default()
209                );
210            } else {
211                if annotations.is_empty() {
212                    eprintln!("No annotations.");
213                    return;
214                }
215                for a in &annotations {
216                    eprintln!(
217                        "{} {} [{}]",
218                        a.id.bold(),
219                        format!("({})", a.updated_at).dimmed(),
220                        a.kind.as_str().cyan()
221                    );
222                    eprintln!("  {}", a.note);
223                    eprintln!();
224                }
225            }
226        }
227        return;
228    }
229
230    let id = match args.id {
231        Some(id) => id,
232        None => {
233            output::error(
234                "Missing required argument: <id>. Run: chub annotate <id> <note> | chub annotate <id> --clear | chub annotate --list",
235                json,
236            );
237            std::process::exit(1);
238        }
239    };
240
241    if args.clear {
242        let (scope, removed) = if args.org {
243            match chub_core::team::org_annotations::clear_org_annotation(&id).await {
244                Ok(r) => ("org", r),
245                Err(e) => {
246                    output::error(&format!("Failed to clear org annotation: {}", e), json);
247                    std::process::exit(1);
248                }
249            }
250        } else if args.team {
251            (
252                "team",
253                chub_core::team::team_annotations::clear_team_annotation(&id),
254            )
255        } else {
256            ("personal", clear_annotation(&id))
257        };
258
259        if json {
260            println!(
261                "{}",
262                serde_json::json!({
263                    "id": id,
264                    "cleared": removed,
265                    "scope": scope,
266                })
267            );
268        } else if removed {
269            eprintln!("{} annotation cleared for {}.", scope, id.bold());
270        } else {
271            eprintln!("No {} annotation found for {}.", scope, id.bold());
272        }
273        return;
274    }
275
276    if let Some(note) = args.note {
277        let kind = parse_kind(args.kind.as_deref());
278
279        if args.org {
280            let author = get_author(args.author.as_deref());
281            match chub_core::team::org_annotations::write_org_annotation(
282                &id,
283                &note,
284                &author,
285                kind.clone(),
286                args.severity.clone(),
287            )
288            .await
289            {
290                Ok(_) => {
291                    if json {
292                        println!(
293                            "{}",
294                            serde_json::json!({
295                                "status": "saved",
296                                "id": id,
297                                "scope": "org",
298                                "kind": kind.as_str(),
299                                "author": author,
300                            })
301                        );
302                    } else {
303                        output::success(&format!(
304                            "Org {} saved for {} (by {})",
305                            kind.as_str(),
306                            id.bold(),
307                            author
308                        ));
309                    }
310                }
311                Err(e) => {
312                    output::error(&format!("Failed to write org annotation: {}", e), json);
313                    std::process::exit(1);
314                }
315            }
316        } else if args.team {
317            // Write team annotation (append semantics — adds to the appropriate section)
318            let author = get_author(args.author.as_deref());
319            match chub_core::team::team_annotations::write_team_annotation(
320                &id,
321                &note,
322                &author,
323                kind.clone(),
324                args.severity.clone(),
325            ) {
326                Some(_ann) => {
327                    if json {
328                        println!(
329                            "{}",
330                            serde_json::json!({
331                                "status": "saved",
332                                "id": id,
333                                "scope": "team",
334                                "kind": kind.as_str(),
335                                "author": author,
336                            })
337                        );
338                    } else {
339                        output::success(&format!(
340                            "Team {} saved for {} (by {})",
341                            kind.as_str(),
342                            id.bold(),
343                            author
344                        ));
345                    }
346                    // After successful team write, check auto_push
347                    let auto_push =
348                        chub_core::team::org_annotations::get_annotation_server_config()
349                            .map(|c| c.auto_push)
350                            .unwrap_or(false);
351                    if auto_push {
352                        let _ = chub_core::team::org_annotations::write_org_annotation(
353                            &id,
354                            &note,
355                            &author,
356                            kind.clone(),
357                            args.severity.clone(),
358                        )
359                        .await;
360                    }
361                }
362                None => {
363                    output::error(
364                        "Failed to save team annotation. Is .chub/ initialized?",
365                        json,
366                    );
367                    std::process::exit(1);
368                }
369            }
370        } else {
371            // Write personal annotation (overwrite semantics — replaces previous note for this entry)
372            let data = write_annotation(&id, &note, kind.clone(), args.severity.clone());
373            if json {
374                println!(
375                    "{}",
376                    serde_json::to_string_pretty(&data).unwrap_or_default()
377                );
378            } else {
379                eprintln!("{} saved for {}.", kind.as_str().cyan(), id.bold());
380            }
381        }
382        return;
383    }
384
385    // Read mode: show existing annotation
386    if args.org {
387        if let Some(ann) = chub_core::team::org_annotations::read_org_annotation(&id).await {
388            if json {
389                println!("{}", serde_json::to_string_pretty(&ann).unwrap_or_default());
390            } else {
391                print_team_annotation_single(&id, &ann);
392            }
393        } else if json {
394            println!("{}", serde_json::json!({ "id": id, "notes": [] }));
395        } else {
396            eprintln!("No org annotation for {}.", id.bold());
397        }
398    } else if args.team {
399        if let Some(ann) = chub_core::team::team_annotations::read_team_annotation(&id) {
400            if json {
401                println!("{}", serde_json::to_string_pretty(&ann).unwrap_or_default());
402            } else {
403                print_team_annotation_single(&id, &ann);
404            }
405        } else if json {
406            println!("{}", serde_json::json!({ "id": id, "notes": [] }));
407        } else {
408            eprintln!("No team annotation for {}.", id.bold());
409        }
410    } else if args.personal {
411        // --personal: read personal tier only
412        let existing = read_annotation(&id);
413        if let Some(ann) = existing {
414            if json {
415                println!("{}", serde_json::to_string_pretty(&ann).unwrap_or_default());
416            } else {
417                eprintln!(
418                    "{} {} [{}]",
419                    ann.id.bold(),
420                    format!("({})", ann.updated_at).dimmed(),
421                    ann.kind.as_str().cyan()
422                );
423                eprintln!("{}", ann.note);
424            }
425        } else if json {
426            println!("{}", serde_json::json!({ "id": id, "note": null }));
427        } else {
428            eprintln!("No personal annotation for {}.", id.bold());
429        }
430    } else {
431        // No flag: show all tiers merged (org + team + personal)
432        let merged = chub_core::team::team_annotations::get_merged_annotation_async(&id).await;
433        if let Some(text) = merged {
434            if json {
435                println!("{}", serde_json::json!({ "id": id, "annotation": text }));
436            } else {
437                eprintln!("{}", id.bold());
438                eprintln!("{}", text);
439            }
440        } else if json {
441            println!("{}", serde_json::json!({ "id": id, "annotation": null }));
442        } else {
443            eprintln!("No annotations for {}.", id.bold());
444        }
445    }
446}