Skip to main content

ignition_core/client/
projects.rs

1//! Project-family capability models (03-01, PROJ-01/02) — the native
2//! `/data/api/v1/projects/*` CRUD family: wire-faithful camelCase
3//! models, verified path constants/builders, and the ONE per-segment
4//! encoder.
5//!
6//! HIGH-confidence endpoints (03-RESEARCH §Verified Endpoint Catalog:
7//! the official 83-api collection + the working ignition-mcp client
8//! agree on every path). Item SHAPES stay MEDIUM until live capture
9//! (research Open Question 2) — hence the `#[serde(flatten)] extra`
10//! passthrough on [`ProjectRecord`] (the 02-02 ModuleInfo pattern:
11//! wire-truth corrections stay cheap).
12//!
13//! Serialization discipline (Pitfall 5): every optional field on the
14//! create/modify bodies is `Option` with `skip_serializing_if` —
15//! absent means NOT SENT, never an empty-string reference
16//! (`"parent": ""` would point at a nonexistent project). Create
17//! always sends `name` + `enabled`; the modify body carries NO `name`
18//! (the PUT must not rename — rename has its own route) and its
19//! `enabled` is itself optional so a single-field `set` never
20//! clobbers the flag.
21//!
22//! Path discipline (Pitfall 6): every `{name}` path segment rides
23//! through [`encode_segment`] (percent-encoding, NON_ALPHANUMERIC set
24//! — over-encoding is safe and mirrors mcp's `quote(name, safe='')`);
25//! a spaced-name recorded-request proof pins it. 03-03's resource
26//! paths encode per-segment through this same fn but keep their `/`
27//! separators.
28
29use std::collections::BTreeMap;
30use std::time::Duration;
31
32use serde::{Deserialize, Serialize};
33
34/// GET path — list every RUNNABLE project (official description).
35pub(crate) const PROJECTS_LIST_PATH: &str = "/data/api/v1/projects/list";
36
37/// POST path — create a project (JSON body).
38pub(crate) const PROJECTS_CREATE_PATH: &str = "/data/api/v1/projects";
39
40/// POST path — copy a project (JSON body `fromName`/`toName`).
41pub(crate) const PROJECTS_COPY_PATH: &str = "/data/api/v1/projects/copy";
42
43/// GET path — one project's full record (`/find/{enc}`).
44pub(crate) fn project_find_path(name: &str) -> String {
45    format!("/data/api/v1/projects/find/{}", encode_segment(name))
46}
47
48/// POST path — rename (`/rename/{enc}` + body `{"name": "<new>"}`).
49pub(crate) fn project_rename_path(name: &str) -> String {
50    format!("/data/api/v1/projects/rename/{}", encode_segment(name))
51}
52
53/// PUT path — modify (`/{enc}`, body WITHOUT `name`). This is the
54/// inheritance move: `set --parent` rides this route.
55pub(crate) fn project_modify_path(name: &str) -> String {
56    format!("/data/api/v1/projects/{}", encode_segment(name))
57}
58
59/// DELETE path — delete (`/{enc}` + `confirm=true` QUERY param — both
60/// guard layers, Pitfall 8).
61pub(crate) fn project_delete_path(name: &str) -> String {
62    format!("/data/api/v1/projects/{}", encode_segment(name))
63}
64
65/// GET path — export (`/export/{enc}`) — the ZIP body streams back
66/// with a `Content-Disposition` filename.
67pub(crate) fn project_export_path(name: &str) -> String {
68    format!("/data/api/v1/projects/export/{}", encode_segment(name))
69}
70
71/// POST path — import (`/import/{enc}` + `overwrite=<bool>` QUERY
72/// param; body = the raw ZIP bytes).
73pub(crate) fn project_import_path(name: &str) -> String {
74    format!("/data/api/v1/projects/import/{}", encode_segment(name))
75}
76
77/// Per-request export timeout (Pitfall 3): 120 s, the logs-download
78/// precedent — `RequestBuilder::timeout`, never a second client and
79/// never a global change.
80pub const PROJECT_EXPORT_TIMEOUT: Duration = Duration::from_secs(120);
81
82/// Per-request import timeout (Pitfall 3, the classic default-timeout
83/// death): imports are heavy and synchronous (no job IDs — verified),
84/// so the upload rides a 300 s budget.
85pub const PROJECT_IMPORT_TIMEOUT: Duration = Duration::from_secs(300);
86
87/// Percent-encode ONE path segment with the NON_ALPHANUMERIC set:
88/// everything outside `[A-Za-z0-9]` is encoded — over-encoding is SAFE
89/// (the server decodes before matching) and mirrors mcp's
90/// `quote(name, safe='')`. Project names with spaces/mixed case ride
91/// the wire intact (`My Project` → `My%20Project`).
92pub(crate) fn encode_segment(segment: &str) -> String {
93    percent_encoding::utf8_percent_encode(segment, percent_encoding::NON_ALPHANUMERIC).to_string()
94}
95
96/// One item of the list/find endpoints — typed core + passthrough
97/// (`defaultDb`/`tagProvider`/`userSource` and every unmodeled key
98/// round-trip so client-seam `--json` stays complete as the gateway
99/// evolves).
100#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
101#[serde(rename_all = "camelCase")]
102pub struct ProjectRecord {
103    /// Project name (unique key; the path segment everywhere).
104    pub name: String,
105    /// Display title.
106    #[serde(default)]
107    pub title: Option<String>,
108    /// Long description.
109    #[serde(default)]
110    pub description: Option<String>,
111    /// Whether the project runs.
112    #[serde(default)]
113    pub enabled: bool,
114    /// Parent project name — the inheritance link.
115    #[serde(default)]
116    pub parent: Option<String>,
117    /// Whether THIS project may serve as a parent (verified: real
118    /// export `project.json` carries it).
119    #[serde(default)]
120    pub inheritable: Option<bool>,
121    /// The gateway's own verdict on this project's parent link: `true` when
122    /// the parent is missing, the project itself or not inheritable (C8e,
123    /// `invalidParent` in `projects/list`). Absent from the wire means the
124    /// gateway did not say; it is then not written back out.
125    #[serde(default, skip_serializing_if = "Option::is_none")]
126    pub invalid_parent: Option<bool>,
127    /// Default database connection name.
128    #[serde(default)]
129    pub default_db: Option<String>,
130    /// Tag provider name.
131    #[serde(default)]
132    pub tag_provider: Option<String>,
133    /// User source name.
134    #[serde(default)]
135    pub user_source: Option<String>,
136    /// Unknown keys round-trip (passthrough-shaped `--json`).
137    #[serde(flatten)]
138    pub extra: BTreeMap<String, serde_json::Value>,
139}
140
141/// POST body — create. `name` + `enabled` are ALWAYS sent; every
142/// optional rides only when provided (Pitfall 5 — an absent optional
143/// is OMITTED, never an empty string referencing a nonexistent
144/// resource). A bare create serializes to exactly
145/// `{"name":…,"enabled":true}` (wiremock recorded-body pin).
146#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
147#[serde(rename_all = "camelCase")]
148pub struct ProjectCreate {
149    /// Project name.
150    pub name: String,
151    /// Whether the project starts enabled (always sent).
152    pub enabled: bool,
153    /// Display title.
154    #[serde(skip_serializing_if = "Option::is_none")]
155    pub title: Option<String>,
156    /// Long description.
157    #[serde(skip_serializing_if = "Option::is_none")]
158    pub description: Option<String>,
159    /// Parent project (inheritance).
160    #[serde(skip_serializing_if = "Option::is_none")]
161    pub parent: Option<String>,
162    /// Whether this project may serve as a parent.
163    #[serde(skip_serializing_if = "Option::is_none")]
164    pub inheritable: Option<bool>,
165    /// Default database connection.
166    #[serde(skip_serializing_if = "Option::is_none")]
167    pub default_db: Option<String>,
168    /// Tag provider.
169    #[serde(skip_serializing_if = "Option::is_none")]
170    pub tag_provider: Option<String>,
171    /// User source.
172    #[serde(skip_serializing_if = "Option::is_none")]
173    pub user_source: Option<String>,
174}
175
176/// PUT body — modify: the create fields MINUS `name` (the PUT must not
177/// rename), with `enabled` itself optional so a single-field `set`
178/// never clobbers it. Same skip-serializing discipline: a
179/// `set --title` body is exactly `{"title":"T"}` (unit-pinned).
180#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
181#[serde(rename_all = "camelCase")]
182pub struct ProjectModify {
183    /// Whether the project runs (sent only when the caller sets it).
184    #[serde(skip_serializing_if = "Option::is_none")]
185    pub enabled: Option<bool>,
186    /// Display title.
187    #[serde(skip_serializing_if = "Option::is_none")]
188    pub title: Option<String>,
189    /// Long description.
190    #[serde(skip_serializing_if = "Option::is_none")]
191    pub description: Option<String>,
192    /// Parent project — the inheritance move.
193    #[serde(skip_serializing_if = "Option::is_none")]
194    pub parent: Option<String>,
195    /// Whether this project may serve as a parent.
196    #[serde(skip_serializing_if = "Option::is_none")]
197    pub inheritable: Option<bool>,
198    /// Default database connection.
199    #[serde(skip_serializing_if = "Option::is_none")]
200    pub default_db: Option<String>,
201    /// Tag provider.
202    #[serde(skip_serializing_if = "Option::is_none")]
203    pub tag_provider: Option<String>,
204    /// User source.
205    #[serde(skip_serializing_if = "Option::is_none")]
206    pub user_source: Option<String>,
207}
208
209/// POST body — copy. Official body keys are `fromName`/`toName`.
210#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
211pub struct ProjectCopy {
212    /// Source project name.
213    #[serde(rename = "fromName")]
214    pub from_name: String,
215    /// Destination name (must not already exist).
216    #[serde(rename = "toName")]
217    pub to_name: String,
218}
219
220/// POST body — rename. The official body key is `name` (the NEW name).
221#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
222pub struct ProjectRenameBody {
223    /// The new name.
224    pub name: String,
225}
226
227/// The export download result — the ZIP was STREAMED to disk (never
228/// buffered in a `Vec<u8>`, Pitfall 2) and this is what the response
229/// metadata said about it. Not serialized into envelopes (the file is
230/// the artifact; the command output model lives in the actions layer).
231#[derive(Debug, Clone)]
232pub struct ExportMeta {
233    /// Filename from `Content-Disposition`, when the header carries one
234    /// (the actions layer sanitizes it into the default output name;
235    /// `None` falls back to `<name>.zip`).
236    pub filename: Option<String>,
237    /// Bytes written to disk (counted chunk-by-chunk as they streamed).
238    pub bytes: u64,
239    /// Response `Content-Type` — sniffed, never assumed.
240    pub content_type: Option<String>,
241}
242
243/// The import result — OPAQUE-SUCCESS (the response body is
244/// unverified MEDIUM; the mcp pattern parses JSON when it can and
245/// falls back to `{"status":"success"}` otherwise — restart's literal
246/// `true` is the same family style).
247#[derive(Debug, Clone)]
248pub struct ImportOutcome {
249    /// The parsed response body when JSON, else the fallback success
250    /// object.
251    pub response: serde_json::Value,
252}
253
254/// Denial detection on a parsed import-response body (05-07, UAT
255/// Gap 1): the gateway refuses imports over HTTP 200 with
256/// `{"success": false, "problem": "…"}` — live-witnessed on the
257/// 8.3.3 rig (an append-member overwrite-import answers exactly
258/// this while landing NOTHING). Returns the problem string when the
259/// body carries an EXPLICIT bool `success: false`; every opaque
260/// family member (missing key, `true`, the `{"status":"success"}`
261/// fallback) returns `None` — we refuse only on an explicit denial,
262/// never on absence of proof.
263pub(crate) fn import_denied(body: &serde_json::Value) -> Option<String> {
264    if body.get("success") != Some(&serde_json::Value::Bool(false)) {
265        return None;
266    }
267    Some(
268        body.get("problem")
269            .and_then(|problem| problem.as_str())
270            .map(str::to_string)
271            .unwrap_or_else(|| {
272                "gateway reported success:false without a problem message".to_string()
273            }),
274    )
275}
276
277#[cfg(test)]
278mod tests {
279    use super::{ProjectCreate, ProjectModify, encode_segment, import_denied};
280
281    /// Pitfall 6: spaces and mixed case encode per segment with the
282    /// NON_ALPHANUMERIC set — over-encoding is safe, under-encoding is
283    /// a broken path.
284    #[test]
285    fn encode_segment_handles_spaces_and_symbols() {
286        assert_eq!(encode_segment("My Project"), "My%20Project");
287        assert_eq!(encode_segment("plain"), "plain");
288        assert_eq!(
289            encode_segment("a/b"),
290            "a%2Fb",
291            "even / encodes — resource paths split first"
292        );
293    }
294
295    /// Pitfall 5: a bare create body is EXACTLY `{"name":…,"enabled":…}`
296    /// — no `"parent":""`, no null keys.
297    #[test]
298    fn bare_create_serializes_exactly_name_and_enabled() {
299        let body = ProjectCreate {
300            name: "x".into(),
301            enabled: true,
302            title: None,
303            description: None,
304            parent: None,
305            inheritable: None,
306            default_db: None,
307            tag_provider: None,
308            user_source: None,
309        };
310        assert_eq!(
311            serde_json::to_value(&body).expect("serializes"),
312            serde_json::json!({"name": "x", "enabled": true}),
313            "absent optionals are OMITTED, never null/empty strings"
314        );
315    }
316
317    /// The Task-2 modify discipline lives at the model too: only the
318    /// provided field rides the PUT body — and never a `name` key.
319    #[test]
320    fn modify_serializes_only_provided_fields() {
321        let body = ProjectModify {
322            enabled: None,
323            title: Some("T".into()),
324            description: None,
325            parent: None,
326            inheritable: None,
327            default_db: None,
328            tag_provider: None,
329            user_source: None,
330        };
331        assert_eq!(
332            serde_json::to_value(&body).expect("serializes"),
333            serde_json::json!({"title": "T"})
334        );
335    }
336
337    /// 05-07 denial detection: an EXPLICIT bool `success:false` yields
338    /// the problem text; every opaque family member (missing key,
339    /// `true`, the fallback object) is NOT a denial — only an explicit
340    /// refusal counts, never absence of proof.
341    #[test]
342    fn import_denied_pins_the_denial_family() {
343        // THE live-witnessed shape: success:false + problem → Some
344        // (the problem text rides verbatim).
345        assert_eq!(
346            import_denied(&serde_json::json!({
347                "success": false,
348                "problem": "resource already exists: ResourceId{resourcePath=com.example, collectionName=views}"
349            }))
350            .as_deref(),
351            Some("resource already exists: ResourceId{resourcePath=com.example, collectionName=views}")
352        );
353        // success:true is success.
354        assert_eq!(import_denied(&serde_json::json!({"success": true})), None);
355        // The opaque fallback family: no success key at all.
356        assert_eq!(
357            import_denied(&serde_json::json!({"status": "success"})),
358            None
359        );
360        assert_eq!(import_denied(&serde_json::json!({})), None);
361        // success:"false" as a STRING is not a bool denial — only
362        // bool false counts.
363        assert_eq!(
364            import_denied(&serde_json::json!({"success": "false"})),
365            None,
366            "string \"false\" is the opaque family, not an explicit denial"
367        );
368        // success:false WITHOUT a problem message still refuses, with
369        // the standing message.
370        assert_eq!(
371            import_denied(&serde_json::json!({"success": false})).as_deref(),
372            Some("gateway reported success:false without a problem message")
373        );
374    }
375}