Skip to main content

bear_rs/
output.rs

1use chrono::{DateTime, Utc};
2use serde_json::{Value, json};
3
4use crate::model::{Attachment, Note, PinRecord, Tag};
5
6// ── Output format ─────────────────────────────────────────────────────────────
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
9pub enum OutputFormat {
10    #[default]
11    Text,
12    Json,
13}
14
15impl std::str::FromStr for OutputFormat {
16    type Err = anyhow::Error;
17    fn from_str(s: &str) -> Result<Self, Self::Err> {
18        match s {
19            "json" => Ok(OutputFormat::Json),
20            "text" | "" => Ok(OutputFormat::Text),
21            other => anyhow::bail!("unknown format: {other}"),
22        }
23    }
24}
25
26// ── Note fields ───────────────────────────────────────────────────────────────
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum NoteField {
30    Id,
31    Title,
32    Tags,
33    Hash,
34    Length,
35    Created,
36    Modified,
37    Pins,
38    Location,
39    Todos,
40    Done,
41    Attachments,
42    Content,
43    Locked,
44}
45
46impl NoteField {
47    fn name(&self) -> &'static str {
48        match self {
49            NoteField::Id => "id",
50            NoteField::Title => "title",
51            NoteField::Tags => "tags",
52            NoteField::Hash => "hash",
53            NoteField::Length => "length",
54            NoteField::Created => "created",
55            NoteField::Modified => "modified",
56            NoteField::Pins => "pins",
57            NoteField::Location => "location",
58            NoteField::Todos => "todos",
59            NoteField::Done => "done",
60            NoteField::Attachments => "attachments",
61            NoteField::Content => "content",
62            NoteField::Locked => "locked",
63        }
64    }
65}
66
67/// Parse a `--fields` value into a list of `NoteField`.
68///
69/// Special values:
70/// - `"all"` → all fields except content
71/// - `"all,content"` → all fields including content
72pub fn parse_note_fields(spec: &str) -> anyhow::Result<Vec<NoteField>> {
73    const ALL_FIELDS: &[NoteField] = &[
74        NoteField::Id,
75        NoteField::Title,
76        NoteField::Tags,
77        NoteField::Hash,
78        NoteField::Length,
79        NoteField::Created,
80        NoteField::Modified,
81        NoteField::Pins,
82        NoteField::Location,
83        NoteField::Todos,
84        NoteField::Done,
85        NoteField::Attachments,
86        NoteField::Locked,
87    ];
88
89    let parts: Vec<&str> = spec.split(',').map(str::trim).collect();
90
91    // Handle "all" expansion
92    let mut fields = Vec::new();
93    for part in &parts {
94        match *part {
95            "all" => fields.extend_from_slice(ALL_FIELDS),
96            "content" => fields.push(NoteField::Content),
97            other => {
98                let f = field_from_str(other)?;
99                fields.push(f);
100            }
101        }
102    }
103    // Deduplicate while preserving order
104    let mut seen = std::collections::HashSet::new();
105    fields.retain(|f| seen.insert(f.name()));
106    Ok(fields)
107}
108
109fn field_from_str(s: &str) -> anyhow::Result<NoteField> {
110    match s {
111        "id" => Ok(NoteField::Id),
112        "title" => Ok(NoteField::Title),
113        "tags" => Ok(NoteField::Tags),
114        "hash" => Ok(NoteField::Hash),
115        "length" => Ok(NoteField::Length),
116        "created" => Ok(NoteField::Created),
117        "modified" => Ok(NoteField::Modified),
118        "pins" => Ok(NoteField::Pins),
119        "location" => Ok(NoteField::Location),
120        "todos" => Ok(NoteField::Todos),
121        "done" => Ok(NoteField::Done),
122        "attachments" => Ok(NoteField::Attachments),
123        "content" => Ok(NoteField::Content),
124        "locked" => Ok(NoteField::Locked),
125        other => anyhow::bail!("invalid field: {other}"),
126    }
127}
128
129/// Default fields for `list` / `search`.
130pub fn default_list_fields() -> Vec<NoteField> {
131    vec![NoteField::Id, NoteField::Title, NoteField::Tags]
132}
133
134/// Default fields for `show`.
135pub fn default_show_fields() -> Vec<NoteField> {
136    vec![
137        NoteField::Id,
138        NoteField::Title,
139        NoteField::Tags,
140        NoteField::Hash,
141        NoteField::Length,
142        NoteField::Created,
143        NoteField::Modified,
144        NoteField::Pins,
145        NoteField::Location,
146        NoteField::Todos,
147        NoteField::Done,
148        NoteField::Attachments,
149        NoteField::Locked,
150    ]
151}
152
153// ── Timestamp formatting ──────────────────────────────────────────────────────
154
155fn fmt_ts(unix: i64) -> String {
156    DateTime::<Utc>::from_timestamp(unix, 0)
157        .map(|dt| dt.format("%Y-%m-%dT%H:%M:%SZ").to_string())
158        .unwrap_or_else(|| unix.to_string())
159}
160
161// ── Note value extraction ─────────────────────────────────────────────────────
162
163fn note_field_value(note: &Note, field: NoteField) -> String {
164    match field {
165        NoteField::Id => note.id.clone(),
166        NoteField::Title => note.title.clone(),
167        NoteField::Tags => note.tags.join(","),
168        NoteField::Hash => note.hash(),
169        NoteField::Length => note.length().to_string(),
170        NoteField::Created => fmt_ts(note.created),
171        NoteField::Modified => fmt_ts(note.modified),
172        NoteField::Pins => note.pinned_in_tags.join(","),
173        NoteField::Location => String::new(), // not in schema
174        NoteField::Todos => note.todo_incompleted.to_string(),
175        NoteField::Done => note.todo_completed.to_string(),
176        NoteField::Attachments => note
177            .attachments
178            .iter()
179            .map(|a| a.filename.as_str())
180            .collect::<Vec<_>>()
181            .join(","),
182        NoteField::Content => note.text.clone(),
183        NoteField::Locked => note.locked.to_string(),
184    }
185}
186
187fn note_field_json(note: &Note, field: NoteField) -> (&'static str, Value) {
188    match field {
189        NoteField::Id => ("id", json!(note.id)),
190        NoteField::Title => ("title", json!(note.title)),
191        NoteField::Tags => ("tags", json!(note.tags)),
192        NoteField::Hash => ("hash", json!(note.hash())),
193        NoteField::Length => ("length", json!(note.length())),
194        NoteField::Created => ("created", json!(fmt_ts(note.created))),
195        NoteField::Modified => ("modified", json!(fmt_ts(note.modified))),
196        NoteField::Pins => ("pins", json!(note.pinned_in_tags)),
197        NoteField::Location => ("location", json!(null)),
198        NoteField::Todos => ("todos", json!(note.todo_incompleted)),
199        NoteField::Done => ("done", json!(note.todo_completed)),
200        NoteField::Attachments => (
201            "attachments",
202            json!(
203                note.attachments
204                    .iter()
205                    .map(|a| json!({"filename": a.filename, "size": a.size}))
206                    .collect::<Vec<_>>()
207            ),
208        ),
209        NoteField::Content => ("content", json!(note.text)),
210        NoteField::Locked => ("locked", json!(note.locked)),
211    }
212}
213
214// ── Print notes ───────────────────────────────────────────────────────────────
215
216pub fn print_notes(notes: &[Note], fields: &[NoteField], format: OutputFormat) {
217    if notes.is_empty() {
218        eprintln!("No notes found.");
219        return;
220    }
221
222    match format {
223        OutputFormat::Text => {
224            for note in notes {
225                let row: Vec<String> = fields.iter().map(|&f| note_field_value(note, f)).collect();
226                println!("{}", row.join("\t"));
227            }
228        }
229        OutputFormat::Json => {
230            let arr: Vec<Value> = notes
231                .iter()
232                .map(|note| {
233                    let mut map = serde_json::Map::new();
234                    for &f in fields {
235                        let (key, val) = note_field_json(note, f);
236                        map.insert(key.to_string(), val);
237                    }
238                    Value::Object(map)
239                })
240                .collect();
241            println!("{}", serde_json::to_string_pretty(&arr).unwrap());
242        }
243    }
244}
245
246pub fn print_note_count(count: usize) {
247    println!("{count}");
248}
249
250// ── Print tags ────────────────────────────────────────────────────────────────
251
252pub fn print_tags(tags: &[Tag], format: OutputFormat) {
253    if tags.is_empty() {
254        eprintln!("No notes found.");
255        return;
256    }
257    match format {
258        OutputFormat::Text => {
259            for tag in tags {
260                println!("{}", tag.name);
261            }
262        }
263        OutputFormat::Json => {
264            let arr: Vec<Value> = tags.iter().map(|t| json!({"name": t.name})).collect();
265            println!("{}", serde_json::to_string_pretty(&arr).unwrap());
266        }
267    }
268}
269
270// ── Print pins ────────────────────────────────────────────────────────────────
271
272pub fn print_pins(pins: &[PinRecord], format: OutputFormat) {
273    if pins.is_empty() {
274        eprintln!("No notes found.");
275        return;
276    }
277    match format {
278        OutputFormat::Text => {
279            for pin in pins {
280                println!("{}", pin.pin);
281            }
282        }
283        OutputFormat::Json => {
284            let arr: Vec<Value> = pins
285                .iter()
286                .map(|p| json!({"noteId": p.note_id, "pin": p.pin}))
287                .collect();
288            println!("{}", serde_json::to_string_pretty(&arr).unwrap());
289        }
290    }
291}
292
293// ── Print attachments ─────────────────────────────────────────────────────────
294
295pub fn print_attachments(attachments: &[Attachment], format: OutputFormat) {
296    if attachments.is_empty() {
297        eprintln!("No notes found.");
298        return;
299    }
300    match format {
301        OutputFormat::Text => {
302            for att in attachments {
303                println!("{}\t{}", att.filename, att.size);
304            }
305        }
306        OutputFormat::Json => {
307            let arr: Vec<Value> = attachments
308                .iter()
309                .map(|a| json!({"filename": a.filename, "size": a.size}))
310                .collect();
311            println!("{}", serde_json::to_string_pretty(&arr).unwrap());
312        }
313    }
314}
315
316#[cfg(test)]
317mod tests {
318    use super::*;
319
320    #[test]
321    fn parse_default_fields() {
322        let fields = parse_note_fields("id,title,tags").unwrap();
323        assert_eq!(
324            fields,
325            vec![NoteField::Id, NoteField::Title, NoteField::Tags]
326        );
327    }
328
329    #[test]
330    fn parse_all_fields() {
331        let fields = parse_note_fields("all").unwrap();
332        assert!(!fields.contains(&NoteField::Content));
333        assert!(fields.contains(&NoteField::Hash));
334    }
335
336    #[test]
337    fn parse_all_with_content() {
338        let fields = parse_note_fields("all,content").unwrap();
339        assert!(fields.contains(&NoteField::Content));
340    }
341
342    #[test]
343    fn parse_unknown_field_errors() {
344        assert!(parse_note_fields("id,bogus").is_err());
345    }
346}