kimun_notes/cli/commands/
properties.rs1use 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#[derive(ValueEnum, Clone, Copy, Debug, Default, PartialEq, Eq)]
20pub enum PropFormat {
21 #[default]
23 Text,
24 Json,
26}
27
28#[derive(Subcommand, Debug)]
29pub enum PropSubcommand {
30 List {
32 path: String,
34 #[arg(long, value_enum, default_value = "text")]
35 format: PropFormat,
36 },
37 Get {
39 path: String,
41 key: String,
43 #[arg(long, value_enum, default_value = "text")]
44 format: PropFormat,
45 },
46 Set {
51 path: String,
53 key: String,
55 #[arg(num_args = 0.., allow_negative_numbers = true)]
58 values: Vec<String>,
59 #[arg(long = "type", value_parser = parse_kind)]
61 kind: Option<PropertyKind>,
62 #[arg(long)]
65 yaml: bool,
66 },
67 Remove {
69 path: String,
71 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
130pub 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
150pub 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}