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    /// Default database connection name.
122    #[serde(default)]
123    pub default_db: Option<String>,
124    /// Tag provider name.
125    #[serde(default)]
126    pub tag_provider: Option<String>,
127    /// User source name.
128    #[serde(default)]
129    pub user_source: Option<String>,
130    /// Unknown keys round-trip (passthrough-shaped `--json`).
131    #[serde(flatten)]
132    pub extra: BTreeMap<String, serde_json::Value>,
133}
134
135/// POST body — create. `name` + `enabled` are ALWAYS sent; every
136/// optional rides only when provided (Pitfall 5 — an absent optional
137/// is OMITTED, never an empty string referencing a nonexistent
138/// resource). A bare create serializes to exactly
139/// `{"name":…,"enabled":true}` (wiremock recorded-body pin).
140#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
141#[serde(rename_all = "camelCase")]
142pub struct ProjectCreate {
143    /// Project name.
144    pub name: String,
145    /// Whether the project starts enabled (always sent).
146    pub enabled: bool,
147    /// Display title.
148    #[serde(skip_serializing_if = "Option::is_none")]
149    pub title: Option<String>,
150    /// Long description.
151    #[serde(skip_serializing_if = "Option::is_none")]
152    pub description: Option<String>,
153    /// Parent project (inheritance).
154    #[serde(skip_serializing_if = "Option::is_none")]
155    pub parent: Option<String>,
156    /// Whether this project may serve as a parent.
157    #[serde(skip_serializing_if = "Option::is_none")]
158    pub inheritable: Option<bool>,
159    /// Default database connection.
160    #[serde(skip_serializing_if = "Option::is_none")]
161    pub default_db: Option<String>,
162    /// Tag provider.
163    #[serde(skip_serializing_if = "Option::is_none")]
164    pub tag_provider: Option<String>,
165    /// User source.
166    #[serde(skip_serializing_if = "Option::is_none")]
167    pub user_source: Option<String>,
168}
169
170/// PUT body — modify: the create fields MINUS `name` (the PUT must not
171/// rename), with `enabled` itself optional so a single-field `set`
172/// never clobbers it. Same skip-serializing discipline: a
173/// `set --title` body is exactly `{"title":"T"}` (unit-pinned).
174#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
175#[serde(rename_all = "camelCase")]
176pub struct ProjectModify {
177    /// Whether the project runs (sent only when the caller sets it).
178    #[serde(skip_serializing_if = "Option::is_none")]
179    pub enabled: Option<bool>,
180    /// Display title.
181    #[serde(skip_serializing_if = "Option::is_none")]
182    pub title: Option<String>,
183    /// Long description.
184    #[serde(skip_serializing_if = "Option::is_none")]
185    pub description: Option<String>,
186    /// Parent project — the inheritance move.
187    #[serde(skip_serializing_if = "Option::is_none")]
188    pub parent: Option<String>,
189    /// Whether this project may serve as a parent.
190    #[serde(skip_serializing_if = "Option::is_none")]
191    pub inheritable: Option<bool>,
192    /// Default database connection.
193    #[serde(skip_serializing_if = "Option::is_none")]
194    pub default_db: Option<String>,
195    /// Tag provider.
196    #[serde(skip_serializing_if = "Option::is_none")]
197    pub tag_provider: Option<String>,
198    /// User source.
199    #[serde(skip_serializing_if = "Option::is_none")]
200    pub user_source: Option<String>,
201}
202
203/// POST body — copy. Official body keys are `fromName`/`toName`.
204#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
205pub struct ProjectCopy {
206    /// Source project name.
207    #[serde(rename = "fromName")]
208    pub from_name: String,
209    /// Destination name (must not already exist).
210    #[serde(rename = "toName")]
211    pub to_name: String,
212}
213
214/// POST body — rename. The official body key is `name` (the NEW name).
215#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
216pub struct ProjectRenameBody {
217    /// The new name.
218    pub name: String,
219}
220
221/// The export download result — the ZIP was STREAMED to disk (never
222/// buffered in a `Vec<u8>`, Pitfall 2) and this is what the response
223/// metadata said about it. Not serialized into envelopes (the file is
224/// the artifact; the command output model lives in the actions layer).
225#[derive(Debug, Clone)]
226pub struct ExportMeta {
227    /// Filename from `Content-Disposition`, when the header carries one
228    /// (the actions layer sanitizes it into the default output name;
229    /// `None` falls back to `<name>.zip`).
230    pub filename: Option<String>,
231    /// Bytes written to disk (counted chunk-by-chunk as they streamed).
232    pub bytes: u64,
233    /// Response `Content-Type` — sniffed, never assumed.
234    pub content_type: Option<String>,
235}
236
237/// The import result — OPAQUE-SUCCESS (the response body is
238/// unverified MEDIUM; the mcp pattern parses JSON when it can and
239/// falls back to `{"status":"success"}` otherwise — restart's literal
240/// `true` is the same family style).
241#[derive(Debug, Clone)]
242pub struct ImportOutcome {
243    /// The parsed response body when JSON, else the fallback success
244    /// object.
245    pub response: serde_json::Value,
246}
247
248/// Denial detection on a parsed import-response body (05-07, UAT
249/// Gap 1): the gateway refuses imports over HTTP 200 with
250/// `{"success": false, "problem": "…"}` — live-witnessed on the
251/// 8.3.3 rig (an append-member overwrite-import answers exactly
252/// this while landing NOTHING). Returns the problem string when the
253/// body carries an EXPLICIT bool `success: false`; every opaque
254/// family member (missing key, `true`, the `{"status":"success"}`
255/// fallback) returns `None` — we refuse only on an explicit denial,
256/// never on absence of proof.
257pub(crate) fn import_denied(body: &serde_json::Value) -> Option<String> {
258    if body.get("success") != Some(&serde_json::Value::Bool(false)) {
259        return None;
260    }
261    Some(
262        body.get("problem")
263            .and_then(|problem| problem.as_str())
264            .map(str::to_string)
265            .unwrap_or_else(|| {
266                "gateway reported success:false without a problem message".to_string()
267            }),
268    )
269}
270
271#[cfg(test)]
272mod tests {
273    use super::{ProjectCreate, ProjectModify, encode_segment, import_denied};
274
275    /// Pitfall 6: spaces and mixed case encode per segment with the
276    /// NON_ALPHANUMERIC set — over-encoding is safe, under-encoding is
277    /// a broken path.
278    #[test]
279    fn encode_segment_handles_spaces_and_symbols() {
280        assert_eq!(encode_segment("My Project"), "My%20Project");
281        assert_eq!(encode_segment("plain"), "plain");
282        assert_eq!(
283            encode_segment("a/b"),
284            "a%2Fb",
285            "even / encodes — resource paths split first"
286        );
287    }
288
289    /// Pitfall 5: a bare create body is EXACTLY `{"name":…,"enabled":…}`
290    /// — no `"parent":""`, no null keys.
291    #[test]
292    fn bare_create_serializes_exactly_name_and_enabled() {
293        let body = ProjectCreate {
294            name: "x".into(),
295            enabled: true,
296            title: None,
297            description: None,
298            parent: None,
299            inheritable: None,
300            default_db: None,
301            tag_provider: None,
302            user_source: None,
303        };
304        assert_eq!(
305            serde_json::to_value(&body).expect("serializes"),
306            serde_json::json!({"name": "x", "enabled": true}),
307            "absent optionals are OMITTED, never null/empty strings"
308        );
309    }
310
311    /// The Task-2 modify discipline lives at the model too: only the
312    /// provided field rides the PUT body — and never a `name` key.
313    #[test]
314    fn modify_serializes_only_provided_fields() {
315        let body = ProjectModify {
316            enabled: None,
317            title: Some("T".into()),
318            description: None,
319            parent: None,
320            inheritable: None,
321            default_db: None,
322            tag_provider: None,
323            user_source: None,
324        };
325        assert_eq!(
326            serde_json::to_value(&body).expect("serializes"),
327            serde_json::json!({"title": "T"})
328        );
329    }
330
331    /// 05-07 denial detection: an EXPLICIT bool `success:false` yields
332    /// the problem text; every opaque family member (missing key,
333    /// `true`, the fallback object) is NOT a denial — only an explicit
334    /// refusal counts, never absence of proof.
335    #[test]
336    fn import_denied_pins_the_denial_family() {
337        // THE live-witnessed shape: success:false + problem → Some
338        // (the problem text rides verbatim).
339        assert_eq!(
340            import_denied(&serde_json::json!({
341                "success": false,
342                "problem": "resource already exists: ResourceId{resourcePath=com.example, collectionName=views}"
343            }))
344            .as_deref(),
345            Some("resource already exists: ResourceId{resourcePath=com.example, collectionName=views}")
346        );
347        // success:true is success.
348        assert_eq!(import_denied(&serde_json::json!({"success": true})), None);
349        // The opaque fallback family: no success key at all.
350        assert_eq!(
351            import_denied(&serde_json::json!({"status": "success"})),
352            None
353        );
354        assert_eq!(import_denied(&serde_json::json!({})), None);
355        // success:"false" as a STRING is not a bool denial — only
356        // bool false counts.
357        assert_eq!(
358            import_denied(&serde_json::json!({"success": "false"})),
359            None,
360            "string \"false\" is the opaque family, not an explicit denial"
361        );
362        // success:false WITHOUT a problem message still refuses, with
363        // the standing message.
364        assert_eq!(
365            import_denied(&serde_json::json!({"success": false})).as_deref(),
366            Some("gateway reported success:false without a problem message")
367        );
368    }
369}