1use std::io::Write;
15
16use crate::cli::Session;
17use crate::exit::ExitCode;
18
19#[derive(Debug)]
21pub struct Intent<'a> {
22 pub action: &'a str,
24 pub targets: &'a [String],
26 pub body: &'a serde_json::Value,
28 pub always_confirm: bool,
34}
35
36#[derive(Debug)]
38pub enum Gate {
39 Proceed,
41 Stop(ExitCode),
43}
44
45#[must_use]
47pub fn check(intent: &Intent<'_>, session: &Session) -> Gate {
48 let mut err = anstream::stderr();
49
50 if let Some(resolved) = &session.resolved {
53 session.announce(resolved);
54 }
55
56 if session.global.dry_run {
57 let _ = writeln!(err, "dry run: would {}", intent.action);
58 let body =
59 serde_json::to_string_pretty(intent.body).unwrap_or_else(|_| intent.body.to_string());
60 let _ = writeln!(err, "{body}");
61 return Gate::Stop(ExitCode::Success);
62 }
63
64 if intent.targets.len() > 1 && !session.global.yes {
68 let _ = writeln!(
69 err,
70 "refusing to {} across {} issues without --yes: {}",
71 intent.action,
72 intent.targets.len(),
73 intent.targets.join(", "),
74 );
75 return Gate::Stop(ExitCode::ConfirmationRequired);
76 }
77
78 if intent.always_confirm && !session.global.yes {
79 let _ = writeln!(
80 err,
81 "refusing to {} without --yes: this one cannot be undone",
82 intent.action,
83 );
84 return Gate::Stop(ExitCode::ConfirmationRequired);
85 }
86
87 Gate::Proceed
88}
89
90pub fn parse_assignment(raw: &str) -> Result<(String, serde_json::Value), String> {
103 if let Some((key, value)) = raw.split_once(":=")
106 && !key.contains('=')
107 {
108 if key.is_empty() {
109 return Err(format!("empty field name in `{raw}`"));
110 }
111 return match serde_json::from_str(value) {
112 Ok(parsed) => Ok((key.to_owned(), parsed)),
113 Err(error) => Err(format!(
114 "`{raw}` says JSON with `:=` and is not valid JSON: {error}"
115 )),
116 };
117 }
118
119 let Some((key, value)) = raw.split_once('=') else {
120 return Err(format!("expected key=value, got `{raw}`"));
121 };
122 if key.is_empty() {
123 return Err(format!("empty field name in `{raw}`"));
124 }
125
126 let parsed =
127 serde_json::from_str(value).unwrap_or_else(|_| serde_json::Value::String(value.to_owned()));
128
129 Ok((key.to_owned(), parsed))
130}
131
132#[cfg(test)]
133mod tests {
134 use super::*;
135
136 #[test]
137 fn numbers_and_booleans_keep_their_type() {
138 assert_eq!(
139 parse_assignment("storyPoints=3"),
140 Ok(("storyPoints".to_owned(), serde_json::json!(3)))
141 );
142 assert_eq!(
143 parse_assignment("flagged=true"),
144 Ok(("flagged".to_owned(), serde_json::json!(true)))
145 );
146 }
147
148 #[test]
149 fn plain_text_stays_a_string() {
150 assert_eq!(
151 parse_assignment("status=In Progress"),
152 Ok(("status".to_owned(), serde_json::json!("In Progress")))
153 );
154 }
155
156 #[test]
157 fn json_arrays_pass_through_as_arrays() {
158 assert_eq!(
159 parse_assignment(r#"tags=["a","b"]"#),
160 Ok(("tags".to_owned(), serde_json::json!(["a", "b"])))
161 );
162 }
163
164 #[test]
166 fn only_the_first_equals_separates() {
167 assert_eq!(
168 parse_assignment("summary=a=b"),
169 Ok(("summary".to_owned(), serde_json::json!("a=b")))
170 );
171 }
172
173 #[test]
175 fn a_json_assignment_is_not_guessed_at() {
176 assert_eq!(
177 parse_assignment(r#"summary:="3""#),
178 Ok(("summary".to_owned(), serde_json::json!("3"))),
179 "a number-shaped summary must be writable as a string"
180 );
181 assert_eq!(
182 parse_assignment("storyPoints:=3"),
183 Ok(("storyPoints".to_owned(), serde_json::json!(3)))
184 );
185 }
186
187 #[test]
190 fn invalid_json_after_the_colon_is_refused() {
191 assert!(parse_assignment("summary:=not json").is_err());
192 }
193
194 #[test]
196 fn only_a_leading_colon_equals_means_json() {
197 assert_eq!(
198 parse_assignment("summary=a:=b"),
199 Ok(("summary".to_owned(), serde_json::json!("a:=b")))
200 );
201 }
202
203 #[test]
204 fn a_pair_without_an_equals_is_rejected() {
205 assert!(parse_assignment("storyPoints").is_err());
206 assert!(parse_assignment("=3").is_err());
207 }
208}