Skip to main content

ignition_core/actions/
resources.rs

1//! Resource actions (05-02 re-point): the surgical edit loop —
2//! list/get/put/delete ONE resource inside a project — riding
3//! project-export ZIP surgery instead of the nonexistent
4//! `/projects/{p}/resources/**` REST routes (the Phase 3 cross-phase
5//! defect, closed here; 05-RESEARCH §Resource Family Decision).
6//! Serde models OUT, no printing (ARCHITECTURE.md layering: the
7//! Phase-6 TUI rides this same layer).
8//!
9//! THE ORCHESTRATION (transport swapped, UX contract untouched):
10//! - list/get: [`GatewayApi::project_export_to_file`] to a temp zip
11//!   → read the bytes → the pure helpers in
12//!   [`crate::client::resources`] (`resource_members` / `read_member`)
13//!   → the existing result shapes. A nonexistent project surfaces
14//!   through export's existing 404 path (`not_found`, exit 6).
15//! - put: sniff the INPUT first (binary refuses before ANY network) →
16//!   export → `replace_member` (append-when-absent = upsert) →
17//!   [`GatewayApi::project_import`] with `overwrite=true` — put
18//!   implicitly REPLACES the entire project, so the CLI guards it
19//!   `--yes` like every destructive verb (05-02; the 03-03 unguarded
20//!   put is superseded, README documents the consequence).
21//! - delete: same surgery with `remove_member` (missing member →
22//!   `not_found`) → import overwrite.
23//!
24//! Perf honesty (research's accepted trade): every resource op
25//! round-trips the WHOLE project zip. Rigs and dev projects are
26//! small; the alternative was the family not working at all.
27//!
28//! The heart that survives from 03-03 is [`classify_content`] — the
29//! classify/HTML-sniffer discipline INVERTED: it sniffs resource
30//! CONTENT to pick the wire representation. The order is the
31//! contract (Pitfall 7):
32//! 1. NUL byte in the first 8 KiB → Binary (refuse — a `data.bin`
33//!    resource must NEVER round-trip through the JSON/text loop;
34//!    export/import owns binary resources);
35//! 2. valid UTF-8 that JSON-parses → Json;
36//! 3. valid UTF-8 → Text;
37//! 4. invalid UTF-8 (no NUL in the head) → Binary all the same.
38//!
39//! The get result keeps the family's stable agent shape
40//! (`{project, path, content_kind, content}` — all keys always
41//! present; `content` is the parsed JSON value or the text as a JSON
42//! string); a Binary get refuses with [`CoreError::ResourceBinary`]
43//! before any result is built — now sniffed from the zip MEMBER
44//! bytes.
45
46use serde::Serialize;
47
48use crate::client::GatewayApi;
49use crate::client::resources::{
50    ResourceEntry, read_member, remove_member, replace_member, resource_members,
51};
52use crate::error::CoreError;
53
54/// How far into a resource body the binary heuristic looks — real
55/// JSON/text payloads never carry NUL anywhere, and 8 KiB catches
56/// every `data.bin`-class resource's magic long before the tail.
57const BINARY_SNIFF_WINDOW: usize = 8 * 1024;
58
59/// The sniffed content kind — see the module docs for the order.
60#[derive(Debug, Clone, PartialEq)]
61pub enum ContentKind {
62    /// Valid UTF-8 that JSON-parsed (the parsed value rides along).
63    Json(serde_json::Value),
64    /// Valid UTF-8 that did not JSON-parse.
65    Text(String),
66    /// NUL in the first 8 KiB, or non-UTF-8 — refused downstream.
67    Binary,
68}
69
70/// Sniff resource bytes: NUL-in-first-8KiB → Binary; UTF-8 +
71/// JSON-parse → Json; UTF-8 → Text; else Binary. Pure — testable
72/// without any gateway.
73pub fn classify_content(bytes: &[u8]) -> ContentKind {
74    let head = &bytes[..bytes.len().min(BINARY_SNIFF_WINDOW)];
75    if head.contains(&0) {
76        return ContentKind::Binary;
77    }
78    match std::str::from_utf8(bytes) {
79        Ok(text) => match serde_json::from_str::<serde_json::Value>(text) {
80            Ok(value) => ContentKind::Json(value),
81            Err(_) => ContentKind::Text(text.to_string()),
82        },
83        Err(_) => ContentKind::Binary,
84    }
85}
86
87impl ContentKind {
88    /// The stable agent-facing label ("json" | "text" — Binary never
89    /// reaches a result; it refuses).
90    fn label(&self) -> &'static str {
91        match self {
92            Self::Json(_) => "json",
93            Self::Text(_) => "text",
94            Self::Binary => "binary",
95        }
96    }
97}
98
99/// `ign resource list` output model: the entries, one path per line
100/// in human mode (surgery-sourced entries carry only `path`).
101#[derive(Debug, Serialize)]
102pub struct ResourcesResult {
103    /// The project's resources (member paths in zip order).
104    pub resources: Vec<ResourceEntry>,
105}
106
107/// `ign resource get` output model — the stable agent shape:
108/// `{project, path, content_kind, content}` (a Binary get refuses
109/// before this is built; `content` is the parsed JSON value or the
110/// text as a JSON string).
111#[derive(Debug, Serialize)]
112pub struct ResourceGetResult {
113    /// The project the resource lives in.
114    pub project: String,
115    /// The resource path.
116    pub path: String,
117    /// "json" | "text" — the sniffed kind.
118    pub content_kind: String,
119    /// The content: parsed JSON (any shape) or the UTF-8 text.
120    pub content: serde_json::Value,
121}
122
123/// `ign resource put` output model.
124#[derive(Debug, Serialize)]
125pub struct ResourcePutResult {
126    /// The project the resource landed in.
127    pub project: String,
128    /// The resource path (created if absent — upsert).
129    pub path: String,
130    /// "json" | "text" — the sniffed kind that rode the surgery.
131    pub content_kind: String,
132}
133
134/// `ign resource delete` output model.
135#[derive(Debug, Serialize)]
136pub struct ResourceDeleteResult {
137    /// The deleted resource's path.
138    pub deleted: String,
139}
140
141/// The shared first half of every resource op: stream the project
142/// export into a unique temp file, then read the bytes back for
143/// in-memory surgery. A nonexistent project fails inside export's
144/// existing classification (404 → `not_found`, exit 6). The
145/// `tempfile` dependency (promoted from dev — already in the
146/// workspace graph) owns uniqueness and cleanup-on-drop.
147///
148/// 07-01: promoted pub — the cross-gateway diff/sync actions ride the
149/// SAME export-to-bytes seam (two clients, one helper).
150pub async fn export_zip_bytes(api: &dyn GatewayApi, project: &str) -> Result<Vec<u8>, CoreError> {
151    let temp = tempfile::NamedTempFile::new()
152        .map_err(|err| CoreError::Internal(format!("cannot create temp export file: {err}")))?;
153    api.project_export_to_file(project, temp.path()).await?;
154    tokio::fs::read(temp.path()).await.map_err(|err| {
155        CoreError::Internal(format!(
156            "cannot read back export {}: {err}",
157            temp.path().display()
158        ))
159    })
160}
161
162/// `ign resource list PROJECT [--prefix P]` — export → member list.
163/// The prefix filters CLIENT-SIDE now (member paths, `starts_with`):
164/// the old server-side `path` query param rode routes that never
165/// existed; the UX contract (one path per line) is unchanged.
166pub async fn resources_list(
167    api: &dyn GatewayApi,
168    project: &str,
169    prefix: Option<&str>,
170) -> Result<ResourcesResult, CoreError> {
171    let zip = export_zip_bytes(api, project).await?;
172    let resources = resource_members(&zip)?
173        .into_iter()
174        .filter(|path| prefix.is_none_or(|prefix| path.starts_with(prefix)))
175        .map(|path| ResourceEntry {
176            path: Some(path),
177            extra: Default::default(),
178        })
179        .collect();
180    Ok(ResourcesResult { resources })
181}
182
183/// `ign resource get PROJECT PATH` — export → member read → sniff →
184/// the stable shape. Binary (now sniffed from the zip member bytes)
185/// → [`CoreError::ResourceBinary`] (exit 6): a `data.bin`-class
186/// resource must never be corrupted through the JSON loop (Pitfall
187/// 7). A missing member is `not_found` from the surgery helper.
188pub async fn resource_get(
189    api: &dyn GatewayApi,
190    project: &str,
191    path: &str,
192) -> Result<ResourceGetResult, CoreError> {
193    let zip = export_zip_bytes(api, project).await?;
194    let bytes = read_member(&zip, path)?;
195    match classify_content(&bytes) {
196        ContentKind::Json(value) => Ok(ResourceGetResult {
197            project: project.to_string(),
198            path: path.to_string(),
199            content_kind: "json".to_string(),
200            content: value,
201        }),
202        ContentKind::Text(text) => Ok(ResourceGetResult {
203            project: project.to_string(),
204            path: path.to_string(),
205            content_kind: "text".to_string(),
206            content: serde_json::Value::String(text),
207        }),
208        ContentKind::Binary => Err(CoreError::ResourceBinary {
209            path: path.to_string(),
210            endpoint: None,
211        }),
212    }
213}
214
215/// `ign resource put PROJECT PATH --file F|-` — sniff the INPUT
216/// first: Binary refuses (exit 6) before ANY network I/O. Then the
217/// surgery loop: export → `replace_member` (append-when-absent =
218/// upsert) → import `overwrite=true`. The import REPLACES the entire
219/// project — replace-not-merge wipes concurrent Designer edits — so
220/// the CLI dispatch guards this verb `--yes` BEFORE resolution (the
221/// 05-02 destructive-verb set; 03-03's unguarded put is superseded).
222pub async fn resource_put(
223    api: &dyn GatewayApi,
224    project: &str,
225    path: &str,
226    input: Vec<u8>,
227) -> Result<ResourcePutResult, CoreError> {
228    let kind = classify_content(&input);
229    if matches!(kind, ContentKind::Binary) {
230        return Err(CoreError::ResourceBinary {
231            path: path.to_string(),
232            endpoint: None,
233        });
234    }
235    let zip = export_zip_bytes(api, project).await?;
236    let surgical = replace_member(&zip, path, &input)?;
237    api.project_import(project, surgical, true).await?;
238    Ok(ResourcePutResult {
239        project: project.to_string(),
240        path: path.to_string(),
241        content_kind: kind.label().to_string(),
242    })
243}
244
245/// `ign resource delete PROJECT PATH` — export → `remove_member`
246/// (missing member → `not_found`) → import overwrite. The `--yes`
247/// guard belongs to the CLI CALLER (it refuses pre-resolution, the
248/// LOCKED 02-03 shape) — this arm only runs once confirmed.
249pub async fn resource_delete(
250    api: &dyn GatewayApi,
251    project: &str,
252    path: &str,
253) -> Result<ResourceDeleteResult, CoreError> {
254    let zip = export_zip_bytes(api, project).await?;
255    let surgical = remove_member(&zip, path)?;
256    api.project_import(project, surgical, true).await?;
257    Ok(ResourceDeleteResult {
258        deleted: path.to_string(),
259    })
260}
261
262#[cfg(test)]
263mod tests {
264    use super::{ContentKind, classify_content};
265
266    /// The sniffer's three outcomes, each pinned: JSON parses (value
267    /// preserved), non-JSON UTF-8 stays text, NUL-in-head is binary.
268    #[test]
269    fn classify_content_sniffs_all_three_kinds() {
270        assert_eq!(
271            classify_content(br#"{"scope":"G","code":"print('hi')"}"#),
272            ContentKind::Json(serde_json::json!({"scope":"G","code":"print('hi')"})),
273            "UTF-8 that JSON-parses → Json (value preserved)"
274        );
275        assert_eq!(
276            classify_content(b"print('just a script')\n"),
277            ContentKind::Text("print('just a script')\n".to_string()),
278            "UTF-8 that does not parse → Text"
279        );
280        assert_eq!(
281            classify_content(&[0x00, 0x50, 0x4B, 0x03]),
282            ContentKind::Binary,
283            "NUL in the head → Binary (data.bin class)"
284        );
285        assert_eq!(
286            classify_content(&[0xFF, 0xFE, 0x00, 0x01]),
287            ContentKind::Binary,
288            "non-UTF-8 → Binary too (no honest textual form)"
289        );
290    }
291
292    /// The NUL window's honest boundary: a NUL PAST the first 8 KiB
293    /// in otherwise-valid UTF-8 is NOT caught by the head heuristic —
294    /// and `from_utf8` accepts NUL as text (0x00 is valid UTF-8), so
295    /// it classifies Text. That is the documented trade: real
296    /// data.bin-class resources carry binary magic well inside the
297    /// window; only adversarial input hides a NUL past it.
298    #[test]
299    fn classify_content_nul_past_window_is_text() {
300        let mut bytes = vec![b'x'; super::BINARY_SNIFF_WINDOW + 64];
301        bytes[super::BINARY_SNIFF_WINDOW + 32] = 0;
302        assert_eq!(
303            classify_content(&bytes),
304            ContentKind::Text(String::from_utf8(bytes.clone()).expect("NUL is valid UTF-8")),
305            "a lone NUL past the 8 KiB window in UTF-8 input classifies Text \
306             (the heuristic's documented boundary)"
307        );
308        // …while the SAME NUL inside the window refuses.
309        bytes[16] = 0;
310        assert_eq!(classify_content(&bytes), ContentKind::Binary);
311    }
312
313    /// The labels the results ride on.
314    #[test]
315    fn content_kind_labels() {
316        assert_eq!(ContentKind::Json(serde_json::json!(1)).label(), "json");
317        assert_eq!(ContentKind::Text(String::new()).label(), "text");
318        assert_eq!(ContentKind::Binary.label(), "binary");
319    }
320}