Skip to main content

kimun_notes/cli/commands/
properties.rs

1// tui/src/cli/commands/properties.rs
2//
3// `kimun note prop …` — read and edit a note's frontmatter properties. Typing
4// rules (inference, the vault-wide type check) live in core; this module only
5// parses arguments and prints.
6
7use clap::{Subcommand, ValueEnum};
8use color_eyre::eyre::Result;
9use kimun_core::NoteVault;
10use kimun_core::note::{
11    FrontmatterFormat, PropertyEntry, PropertyInput, PropertyKind, PropertyValue,
12};
13
14use crate::cli::UserError;
15use crate::cli::helpers::resolve_note_path;
16use crate::cli::json_output::JsonProperties;
17
18/// Output format of `prop list` / `prop get`.
19#[derive(ValueEnum, Clone, Copy, Debug, Default, PartialEq, Eq)]
20pub enum PropFormat {
21    /// `key: value` lines (`get` prints just the value)
22    #[default]
23    Text,
24    /// One JSON object (`get` prints just the JSON value)
25    Json,
26}
27
28#[derive(Subcommand, Debug)]
29pub enum PropSubcommand {
30    /// List a note's frontmatter properties
31    List {
32        /// Note path, relative to quick_note_path or absolute from vault root
33        path: String,
34        #[arg(long, value_enum, default_value = "text")]
35        format: PropFormat,
36    },
37    /// Print one property's value (fails when the note doesn't have it)
38    Get {
39        /// Note path, relative to quick_note_path or absolute from vault root
40        path: String,
41        /// Property key (case-insensitive)
42        key: String,
43        #[arg(long, value_enum, default_value = "text")]
44        format: PropFormat,
45    },
46    /// Set a property. One value is typed by the type the key has elsewhere in
47    /// the vault, or by its look for a new key; several values make a list;
48    /// `tags` is always a list (comma-separated values split into items). A value that doesn't fit the vault's type is
49    /// refused unless --type is given.
50    Set {
51        /// Note path, relative to quick_note_path or absolute from vault root
52        path: String,
53        /// Property key (case-insensitive; a new key is written as spelled)
54        key: String,
55        /// The value, or several for a list (none: an empty list, for `tags`
56        /// or with `--type list`)
57        #[arg(num_args = 0.., allow_negative_numbers = true)]
58        values: Vec<String>,
59        /// Force a type: text, number, bool, date, datetime or list. Changes only this note.
60        #[arg(long = "type", value_parser = parse_kind)]
61        kind: Option<PropertyKind>,
62        /// Write a new frontmatter block as YAML (`---`) instead of TOML (`+++`).
63        /// An existing block always keeps its format.
64        #[arg(long)]
65        yaml: bool,
66    },
67    /// Remove a property (no error when the note doesn't have it)
68    Remove {
69        /// Note path, relative to quick_note_path or absolute from vault root
70        path: String,
71        /// Property key (case-insensitive)
72        key: String,
73    },
74}
75
76fn parse_kind(s: &str) -> Result<PropertyKind, String> {
77    s.parse()
78}
79
80pub async fn run(
81    subcommand: PropSubcommand,
82    vault: &NoteVault,
83    quick_note_path: &str,
84) -> Result<()> {
85    match subcommand {
86        PropSubcommand::List { path, format } => {
87            let path = resolve_note_path(&path, quick_note_path)?;
88            let properties = vault.get_properties(&path).await?;
89            print!("{}", format_properties(&properties, format)?);
90        }
91        PropSubcommand::Get { path, key, format } => {
92            let path = resolve_note_path(&path, quick_note_path)?;
93            let value = vault
94                .get_property(&path, &key)
95                .await?
96                .ok_or_else(|| UserError(format!("No property '{key}' in {path}")))?;
97            println!("{}", format_value(value.as_ref(), format)?);
98        }
99        PropSubcommand::Set {
100            path,
101            key,
102            values,
103            kind,
104            yaml,
105        } => {
106            let path = resolve_note_path(&path, quick_note_path)?;
107            let new_block_format = if yaml {
108                FrontmatterFormat::Yaml
109            } else {
110                FrontmatterFormat::Toml
111            };
112            let input = PropertyInput::new(values).forced(kind);
113            let value = vault
114                .set_property_from_input(&path, &key, &input, new_block_format)
115                .await?;
116            println!("Set {key} = {value} ({}) in {path}", value.kind());
117        }
118        PropSubcommand::Remove { path, key } => {
119            let path = resolve_note_path(&path, quick_note_path)?;
120            if vault.remove_property(&path, &key).await? {
121                println!("Removed {key} from {path}");
122            } else {
123                println!("No property '{key}' in {path}");
124            }
125        }
126    }
127    Ok(())
128}
129
130/// A property list as `key: value` lines (`key:` for a key with no value),
131/// or one JSON object in file order.
132pub fn format_properties(properties: &[PropertyEntry], format: PropFormat) -> Result<String> {
133    Ok(match format {
134        PropFormat::Text => properties
135            .iter()
136            .map(|(key, value)| match value {
137                Some(value) => format!("{key}: {value}\n"),
138                None => format!("{key}:\n"),
139            })
140            .collect(),
141        PropFormat::Json => {
142            format!(
143                "{}\n",
144                serde_json::to_string(&JsonProperties(properties.to_vec()))?
145            )
146        }
147    })
148}
149
150/// One value as plain text, or as its JSON value; a key with no readable
151/// value is an empty line, or `null`.
152pub fn format_value(value: Option<&PropertyValue>, format: PropFormat) -> Result<String> {
153    Ok(match format {
154        PropFormat::Text => value.map(ToString::to_string).unwrap_or_default(),
155        PropFormat::Json => serde_json::to_string(&value)?,
156    })
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162
163    fn sample() -> Vec<PropertyEntry> {
164        vec![
165            ("status".into(), Some(PropertyValue::Text("done".into()))),
166            ("priority".into(), Some(PropertyValue::Number(2.0))),
167            ("due".into(), None),
168            (
169                "tags".into(),
170                Some(PropertyValue::List(vec!["a".into(), "b c".into()])),
171            ),
172        ]
173    }
174
175    #[test]
176    fn lists_as_lines_or_ordered_json() {
177        assert_eq!(
178            format_properties(&sample(), PropFormat::Text).unwrap(),
179            "status: done\npriority: 2\ndue:\ntags: a, b c\n"
180        );
181        assert_eq!(
182            format_properties(&sample(), PropFormat::Json).unwrap(),
183            "{\"status\":\"done\",\"priority\":2,\"due\":null,\"tags\":[\"a\",\"b c\"]}\n",
184            "keys keep the note's order; a key with no value is null"
185        );
186        assert_eq!(format_properties(&[], PropFormat::Json).unwrap(), "{}\n");
187    }
188
189    #[derive(clap::Parser)]
190    struct Cli {
191        #[command(subcommand)]
192        prop: PropSubcommand,
193    }
194
195    fn parse_set(args: &[&str]) -> (Vec<String>, Option<PropertyKind>) {
196        use clap::Parser;
197        let argv = ["kimun", "set", "n", "k"].iter().chain(args);
198        match Cli::try_parse_from(argv).unwrap().prop {
199            PropSubcommand::Set { values, kind, .. } => (values, kind),
200            other => panic!("parsed as {other:?}"),
201        }
202    }
203
204    #[test]
205    fn set_flags_after_values_are_flags_and_negatives_are_values() {
206        assert_eq!(
207            parse_set(&["high", "--type", "text"]),
208            (vec!["high".to_string()], Some(PropertyKind::Text))
209        );
210        assert_eq!(parse_set(&["-5"]), (vec!["-5".to_string()], None));
211        assert_eq!(
212            parse_set(&["a", "b", "--type", "list"]),
213            (
214                vec!["a".to_string(), "b".to_string()],
215                Some(PropertyKind::List)
216            )
217        );
218        assert_eq!(
219            parse_set(&["--type", "list"]),
220            (vec![], Some(PropertyKind::List))
221        );
222    }
223
224    #[test]
225    fn formats_one_value() {
226        let tags = PropertyValue::List(vec!["a".into()]);
227        assert_eq!(format_value(Some(&tags), PropFormat::Text).unwrap(), "a");
228        assert_eq!(
229            format_value(Some(&tags), PropFormat::Json).unwrap(),
230            "[\"a\"]"
231        );
232        assert_eq!(format_value(None, PropFormat::Text).unwrap(), "");
233        assert_eq!(format_value(None, PropFormat::Json).unwrap(), "null");
234    }
235}