Skip to main content

ytcli/cli/
entity.rs

1//! Shared implementation for the entity commands.
2//!
3//! Projects, portfolios and goals are the same endpoint family with a different
4//! type name, so they share everything but the word.
5
6use crate::cli::write::{Gate, Intent, check};
7use crate::cli::{Session, emit, report};
8use crate::exit::ExitCode;
9use crate::render::{Format, entity as render, machine};
10
11/// List entities of one kind.
12pub async fn list(kind: &str, query: Option<&str>, page: u32, session: &Session) -> ExitCode {
13    let client = match session.client() {
14        Ok(client) => client,
15        Err(code) => return code,
16    };
17
18    let display = session.display();
19    let Ok(per_page) = u32::try_from(display.limit.max(1)) else {
20        return report(&"limit is too large", ExitCode::ConfirmationRequired);
21    };
22
23    match client.entities(kind, query, page.max(1), per_page).await {
24        Ok(found) => {
25            let rendered = match session.render.format {
26                Format::Text => Ok(render::entities(&found, &session.render)),
27                Format::JsonRaw => machine(&found.items, Format::Json),
28                other => machine(&found.items, other),
29            };
30            finish(rendered)
31        }
32        Err(error) => {
33            let code = error.exit_code();
34            report(&error, code)
35        }
36    }
37}
38
39/// List what a portfolio contains.
40pub async fn contents(id: &str, page: u32, session: &Session) -> ExitCode {
41    let client = match session.client() {
42        Ok(client) => client,
43        Err(code) => return code,
44    };
45
46    let display = session.display();
47    let Ok(per_page) = u32::try_from(display.limit.max(1)) else {
48        return report(&"limit is too large", ExitCode::ConfirmationRequired);
49    };
50
51    match client.entities_in(id, page.max(1), per_page).await {
52        Ok(found) => {
53            let rendered = match session.render.format {
54                Format::Text => Ok(render::contents(&found, &session.render)),
55                Format::JsonRaw => machine(&found.items, Format::Json),
56                other => machine(&found.items, other),
57            };
58            finish(rendered)
59        }
60        Err(error) => {
61            let code = error.exit_code();
62            report(&error, code)
63        }
64    }
65}
66
67/// Show one entity.
68pub async fn get(kind: &str, id: &str, session: &Session) -> ExitCode {
69    let client = match session.client() {
70        Ok(client) => client,
71        Err(code) => return code,
72    };
73
74    match client.entity(kind, id).await {
75        Ok(found) => {
76            let rendered = match session.render.format {
77                Format::Text => Ok(render::entity(&found, &session.render)),
78                Format::JsonRaw => machine(&found, Format::Json),
79                other => machine(&found, other),
80            };
81            finish(rendered)
82        }
83        Err(error) => {
84            let code = error.exit_code();
85            report(&error, code)
86        }
87    }
88}
89
90fn finish(rendered: Result<String, crate::render::RenderError>) -> ExitCode {
91    match rendered {
92        Ok(text) => {
93            emit(&text);
94            ExitCode::Success
95        }
96        Err(error) => report(&error, ExitCode::Failure),
97    }
98}
99
100/// Fields a project, portfolio or goal can be given on the command line.
101///
102/// Deliberately few. Everything else an entity has is either a reference to
103/// something the caller would have to look up first, or prose that belongs in
104/// the web interface rather than in shell quoting.
105#[derive(Debug, Default, clap::Args)]
106pub struct Fields {
107    /// Name.
108    #[arg(long, short = 's')]
109    pub summary: Option<String>,
110    /// Description.
111    #[arg(long, short = 'd')]
112    pub description: Option<String>,
113    /// Login of whoever owns it.
114    #[arg(long)]
115    pub lead: Option<String>,
116    /// Start date, as 2026-09-01.
117    #[arg(long)]
118    pub start: Option<String>,
119    /// End date, as 2026-12-31.
120    #[arg(long)]
121    pub end: Option<String>,
122}
123
124impl Fields {
125    /// What was actually given, as Tracker's field names.
126    fn body(&self) -> serde_json::Map<String, serde_json::Value> {
127        let mut fields = serde_json::Map::new();
128        for (name, value) in [
129            ("summary", &self.summary),
130            ("description", &self.description),
131            ("lead", &self.lead),
132            ("start", &self.start),
133            ("end", &self.end),
134        ] {
135            if let Some(value) = value {
136                fields.insert(name.to_owned(), serde_json::json!(value));
137            }
138        }
139        fields
140    }
141}
142
143/// Create a project, portfolio or goal.
144pub async fn create(kind: &str, fields: &Fields, session: &Session) -> ExitCode {
145    let client = match session.client() {
146        Ok(client) => client,
147        Err(code) => return code,
148    };
149
150    let Some(summary) = fields.summary.clone() else {
151        return report(
152            &format!("a {kind} needs a name: --summary"),
153            ExitCode::ConfirmationRequired,
154        );
155    };
156
157    let body = serde_json::Value::Object(fields.body());
158    let targets = [summary];
159    let intent = Intent {
160        action: &format!("create a {kind}"),
161        targets: &targets,
162        body: &body,
163        always_confirm: false,
164    };
165    if let Gate::Stop(code) = check(&intent, session) {
166        return code;
167    }
168
169    match client.create_entity(kind, &body).await {
170        Ok(created) => show(&created, session),
171        Err(error) => {
172            let code = error.exit_code();
173            report(&error, code)
174        }
175    }
176}
177
178/// Change the fields of one.
179///
180/// Two requests, like `place`: the entity is read first for its version, so a
181/// change somebody else made in between is refused rather than overwritten.
182pub async fn update(kind: &str, id: &str, fields: &Fields, session: &Session) -> ExitCode {
183    let client = match session.client() {
184        Ok(client) => client,
185        Err(code) => return code,
186    };
187
188    let body = fields.body();
189    if body.is_empty() {
190        return report(
191            &"nothing to change: pass --summary, --description, --lead, --start or --end",
192            ExitCode::ConfirmationRequired,
193        );
194    }
195    let body = serde_json::Value::Object(body);
196
197    let current = match client.entity(kind, id).await {
198        Ok(entity) => entity,
199        Err(error) => {
200            let code = error.exit_code();
201            return report(&error, code);
202        }
203    };
204
205    let intent = Intent {
206        action: &format!("change {kind} {id}"),
207        targets: std::slice::from_ref(&current.id),
208        body: &body,
209        always_confirm: false,
210    };
211    if let Gate::Stop(code) = check(&intent, session) {
212        return code;
213    }
214
215    match client.update_entity(kind, id, &body, current.version).await {
216        Ok(updated) => show(&updated, session),
217        Err(error) => {
218            let code = error.exit_code();
219            report(&error, code)
220        }
221    }
222}
223
224/// Delete one.
225///
226/// `always_confirm`, like `queue create`: this is irreversible in kind rather
227/// than at scale. Everything the entity grouped survives — a project holds no
228/// issues of its own — but the grouping itself does not come back.
229pub async fn remove(kind: &str, id: &str, session: &Session) -> ExitCode {
230    let client = match session.client() {
231        Ok(client) => client,
232        Err(code) => return code,
233    };
234
235    // Read first, so the confirmation names what is about to go rather than an
236    // id, and so a mistyped one fails before the gate rather than after it.
237    let current = match client.entity(kind, id).await {
238        Ok(entity) => entity,
239        Err(error) => {
240            let code = error.exit_code();
241            return report(&error, code);
242        }
243    };
244
245    let body = serde_json::json!({ "delete": id });
246    let intent = Intent {
247        action: &format!("delete {kind} `{}`", current.summary),
248        targets: std::slice::from_ref(&current.id),
249        body: &body,
250        always_confirm: true,
251    };
252    if let Gate::Stop(code) = check(&intent, session) {
253        return code;
254    }
255
256    match client.delete_entity(kind, id).await {
257        Ok(()) => {
258            emit(&format!("{kind} {id} deleted\n"));
259            ExitCode::Success
260        }
261        Err(error) => {
262            let code = error.exit_code();
263            report(&error, code)
264        }
265    }
266}
267
268/// An entity as it now stands, after a write.
269fn show(entity: &crate::api::models::Entity, session: &Session) -> ExitCode {
270    let rendered = match session.render.format {
271        Format::Text => Ok(render::entity(entity, &session.render)),
272        Format::JsonRaw => machine(entity, Format::Json),
273        other => machine(entity, other),
274    };
275    finish(rendered)
276}
277
278/// Put an entity inside a portfolio, or take it out of one.
279///
280/// Two requests: the entity is read first for its version, so a portfolio that
281/// somebody else moved in the meantime is refused by Tracker rather than
282/// overwritten. The read also means a mistyped id fails before anything is
283/// written.
284pub async fn place(kind: &str, id: &str, parent: Option<&str>, session: &Session) -> ExitCode {
285    let client = match session.client() {
286        Ok(client) => client,
287        Err(code) => return code,
288    };
289
290    let current = match client.entity(kind, id).await {
291        Ok(entity) => entity,
292        Err(error) => {
293            let code = error.exit_code();
294            return report(&error, code);
295        }
296    };
297
298    let action = match parent {
299        Some(parent) => format!("put {kind} {id} into portfolio {parent}"),
300        None => format!("take {kind} {id} out of its portfolio"),
301    };
302    let body = serde_json::json!({
303        "fields": { "parentEntity": parent }
304    });
305    let intent = Intent {
306        action: &action,
307        targets: std::slice::from_ref(&current.id),
308        body: &body,
309        always_confirm: false,
310    };
311    if let Gate::Stop(code) = check(&intent, session) {
312        return code;
313    }
314
315    match client.place_entity(kind, id, parent, current.version).await {
316        Ok(placed) => {
317            let rendered = match session.render.format {
318                Format::Text => Ok(render::entity(&placed, &session.render)),
319                Format::JsonRaw => machine(&placed, Format::Json),
320                other => machine(&placed, other),
321            };
322            finish(rendered)
323        }
324        Err(error) => {
325            let code = error.exit_code();
326            report(&error, code)
327        }
328    }
329}