Skip to main content

ytcli/cli/
write.rs

1//! The gate every writing command passes through.
2//!
3//! Two rules, from ADR 1:
4//!
5//! * `--dry-run` prints the request that would be sent and sends nothing.
6//! * A change that fans out across more than one issue needs `--yes`. A single
7//!   issue does not: this is a tool for changing issues, and confirming each one
8//!   would be theatre rather than safety.
9//!
10//! Both paths announce the profile and organisation first. A change applied to
11//! the wrong organisation is expensive to reconstruct afterwards, and "which one
12//! was I on" should never be a question the output leaves open.
13
14use std::io::Write;
15
16use crate::cli::Session;
17use crate::exit::ExitCode;
18
19/// What a write is about to do, in the words the user will see.
20#[derive(Debug)]
21pub struct Intent<'a> {
22    /// Imperative summary, e.g. `create an issue in PROJ`.
23    pub action: &'a str,
24    /// The issue keys affected, when they are known ahead of time.
25    pub targets: &'a [String],
26    /// The request body that would be sent.
27    pub body: &'a serde_json::Value,
28    /// Ask for `--yes` even for a single target.
29    ///
30    /// For work that is irreversible in kind rather than at scale: a queue key
31    /// is claimed once, and Tracker deletes a queue by hiding it, so the key
32    /// stays spent whatever happens next.
33    pub always_confirm: bool,
34}
35
36/// Outcome of the gate.
37#[derive(Debug)]
38pub enum Gate {
39    /// Go ahead.
40    Proceed,
41    /// Stop, with this exit code.
42    Stop(ExitCode),
43}
44
45/// Announce the write, then decide whether it may proceed.
46#[must_use]
47pub fn check(intent: &Intent<'_>, session: &Session) -> Gate {
48    let mut err = anstream::stderr();
49
50    // The same line every command prints, and only once: by the time a write
51    // reaches the gate the client has usually already said which profile it is.
52    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    // One issue is the ordinary case and needs no ceremony. Several is different
65    // in kind: irreversible at scale, and usually the result of a filter that
66    // matched more than the caller pictured.
67    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
90/// Parse a `key=value` or `key:=json` pair from `--set`.
91///
92/// `key=value` guesses: the value is read as JSON when it parses as one, so
93/// `--set storyPoints=3` sends a number and `--set tags=["a","b"]` sends an
94/// array, and anything else is a string. Guessing from the queue's field
95/// metadata instead would cost a request on every update to answer a question
96/// the caller already knows.
97///
98/// The cost of the guess is that a summary which happens to look like a number
99/// becomes one. `key:=json` is the way to say what was meant: the value is JSON
100/// and only JSON, so `--set 'summary:="3"'` writes the string and invalid JSON
101/// is a refusal rather than a silent fall back to text.
102pub fn parse_assignment(raw: &str) -> Result<(String, serde_json::Value), String> {
103    // `:=` only counts when it comes first: `--set summary=a:=b` is a value
104    // containing a colon, not a JSON assignment with an odd key.
105    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    /// A value containing `=` belongs to the value, not to a second split.
165    #[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    /// The escape hatch from the guess: JSON, and only JSON.
174    #[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    /// Saying JSON and not writing JSON is a mistake worth naming, not
188    /// something to quietly turn back into text.
189    #[test]
190    fn invalid_json_after_the_colon_is_refused() {
191        assert!(parse_assignment("summary:=not json").is_err());
192    }
193
194    /// `:=` inside a value is part of the value.
195    #[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}