Skip to main content

ignition_core/actions/
projects.rs

1//! Project actions (03-01, PROJ-01/02): list with inheritance info,
2//! new, copy, rename, set (reparent), delete — serde models OUT, no
3//! printing (ARCHITECTURE.md layering: the Phase-6 TUI rides this same
4//! layer).
5//!
6//! 03-02 (PROJ-03/04) adds export/import: the export result carries
7//! the static scope metadata (what a project ZIP does and does not
8//! contain — roadmap criterion 4), and the import action owns the
9//! collision policy — the abort pre-check refuses via `project_find`
10//! BEFORE any upload; overwrite skips the pre-check (the server is
11//! the authority) and dispatch guards it as destructive.
12//!
13//! Two-column naming (LOCKED): client models stay wire-faithful; these
14//! action results re-expose the SELECTED fields under unit-explicit
15//! snake_case keys, ALL keys always present (null when absent) — the
16//! stable agent shape; agents must never key-hunt.
17//!
18//! Every mutation READS BACK via `project_find` — the create/copy/
19//! rename/modify response bodies are unverified LOW (the restart
20//! `literal true` precedent), so the record the gateway answers with
21//! IS the truth the CLI reports.
22//!
23//! The `parents`/`parents/{name}` endpoints stay OUT of scope: the
24//! server is the reparent authority (cycle guard), and PROJ-01's
25//! inheritance info comes from the list items themselves.
26
27use std::path::{Path, PathBuf};
28
29use serde::Serialize;
30
31use crate::client::GatewayApi;
32use crate::client::projects::{ProjectCreate, ProjectModify, ProjectRecord};
33use crate::client::query::ListQuery;
34use crate::error::CoreError;
35
36/// What a project export INCLUDES — the static, documented-once
37/// arrays (HIGH confidence: verified from a real git-module-managed
38/// 8.3 export tree). Data, not prose — agents key off them (roadmap
39/// criterion 4).
40pub const EXPORT_INCLUDES: &[&str] = &[
41    "views",
42    "scripts",
43    "named-queries",
44    "vision-windows",
45    "perspective-themes-styles",
46    "reporting",
47    "alarm-notification-profiles",
48    "webdev-routes",
49    "translations",
50    "sfc-charts",
51];
52
53/// What a project export EXCLUDES — tag providers, tags, and UDTs are
54/// GATEWAY CONFIGURATION, not project resources (the git-module
55/// convention keeps a separate `tags/` tree precisely because of
56/// this).
57pub const EXPORT_EXCLUDES: &[&str] = &[
58    "tag-providers",
59    "tags",
60    "udts",
61    "gateway-config",
62    "database-connections",
63    "users-roles",
64    "alarm-journal",
65    "certificates",
66];
67
68/// The scope metadata carried in BOTH export and import JSON data —
69/// identical consts, so the statement "what this ZIP does and does
70/// not contain" never drifts between the two commands.
71#[derive(Debug, Clone, PartialEq, Serialize)]
72pub struct ExportScope {
73    /// Resource families present in a project ZIP.
74    pub includes: Vec<&'static str>,
75    /// Resource families that live in gateway config instead.
76    pub excludes: Vec<&'static str>,
77}
78
79impl ExportScope {
80    /// Build from the static consts (the single source).
81    pub fn new() -> Self {
82        Self {
83            includes: EXPORT_INCLUDES.to_vec(),
84            excludes: EXPORT_EXCLUDES.to_vec(),
85        }
86    }
87}
88
89impl Default for ExportScope {
90    fn default() -> Self {
91        Self::new()
92    }
93}
94
95/// Import sanity limit — 512 MB (a real project export is MB-scale;
96/// anything past this is a wrong file, not a project). Checked
97/// BEFORE any network I/O.
98pub const IMPORT_MAX_BYTES: usize = 512 * 1024 * 1024;
99
100/// The local-file-header magic every ZIP carries (`PK\x03\x04`) —
101/// the cheap wrong-file guard (Don't-Hand-Roll table: the gateway
102/// validates imports; this catches the common mistake).
103const ZIP_MAGIC: [u8; 4] = [0x50, 0x4B, 0x03, 0x04];
104
105/// The import collision policy. REST exposes exactly abort and
106/// overwrite — `merge` is the Designer import popup's vocabulary and
107/// is rejected at the CLI value-enum level (README documents it as
108/// Designer-only).
109#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
110pub enum CollisionPolicy {
111    /// Refuse when the project already exists (default) — the find
112    /// pre-check fires BEFORE any upload.
113    Abort,
114    /// Replace the ENTIRE project: resources absent from the ZIP are
115    /// DELETED (replace, not merge — Pitfall 4). Destructive: the CLI
116    /// guards it with `--yes`.
117    Overwrite,
118}
119
120impl CollisionPolicy {
121    /// The stable agent-facing label.
122    pub fn label(self) -> &'static str {
123        match self {
124            Self::Abort => "abort",
125            Self::Overwrite => "overwrite",
126        }
127    }
128}
129
130/// The >512 MB refusal as a pure size check — testable without a
131/// half-gigabyte allocation.
132fn import_size_error(len: usize) -> Option<CoreError> {
133    (len > IMPORT_MAX_BYTES).then(|| CoreError::InvalidImportFile {
134        reason: format!(
135            "{len} bytes exceeds the {} MB sanity limit",
136            IMPORT_MAX_BYTES / (1024 * 1024)
137        ),
138    })
139}
140
141/// The cheap wrong-file guards, both usage-class (exit 2): the
142/// `PK\x03\x04` magic and the 512 MB sanity limit. Runs BEFORE any
143/// network I/O (the find pre-check included).
144///
145/// `pub(crate)`: `deploy projects` runs it on the artifact it built (25-06),
146/// so a zip that would not pass `projects import` is never reported as ready
147/// or uploaded.
148pub(crate) fn validate_import(zip: &[u8]) -> Result<(), CoreError> {
149    if !zip.starts_with(&ZIP_MAGIC) {
150        return Err(CoreError::InvalidImportFile {
151            reason: "missing ZIP magic (PK\\x03\\x04) — not a project export archive".to_string(),
152        });
153    }
154    if let Some(err) = import_size_error(zip.len()) {
155        return Err(err);
156    }
157    // (05-07, Rule 2) Full-structure validation BEFORE any upload:
158    // live-witnessed on 8.3.3, a TRUNCATED zip (valid magic, broken
159    // tail) imports with `{"success":true,"changes":[]}` and — on
160    // overwrite — REPLACES the project with the partial contents
161    // (data loss wearing a success face). Walking every member and
162    // decompressing it catches truncation/corruption here, where the
163    // refusal names the caller's own file to fix (exit 2, zero
164    // network).
165    let mut archive = zip::ZipArchive::new(std::io::Cursor::new(zip)).map_err(|err| {
166        CoreError::InvalidImportFile {
167            reason: format!("not a readable ZIP archive: {err}"),
168        }
169    })?;
170    for index in 0..archive.len() {
171        let mut file = archive
172            .by_index(index)
173            .map_err(|err| CoreError::InvalidImportFile {
174                reason: format!("cannot read import archive member {index}: {err}"),
175            })?;
176        let name = file.name().to_string();
177        let mut sink = Vec::new();
178        std::io::Read::read_to_end(&mut file, &mut sink).map_err(|err| {
179            CoreError::InvalidImportFile {
180                reason: format!("cannot decompress import member {name:?}: {err}"),
181            }
182        })?;
183    }
184    Ok(())
185}
186
187/// Strip any path components from a `Content-Disposition` basename —
188/// the gateway names exports well, but a disposition value is header
189/// input and never deserves path trust. `.`/`..`/empty refuse (the
190/// caller falls back to `<name>.zip`).
191fn sanitize_basename(raw: &str) -> Option<String> {
192    let name = raw.rsplit(['/', '\\']).next().unwrap_or(raw).trim();
193    if name.is_empty() || name == "." || name == ".." {
194        None
195    } else {
196        Some(name.to_string())
197    }
198}
199
200/// A filesystem-safe fallback stem for the default export name — a
201/// project name is a single segment on the wire, but defense-in-depth
202/// replaces any separator that somehow rides along.
203fn safe_fallback_stem(name: &str) -> String {
204    name.replace(['/', '\\'], "_")
205}
206
207/// One project row — the six fields PROJ-01 names.
208#[derive(Debug, Clone, PartialEq, Serialize)]
209pub struct ProjectSummary {
210    /// Project name (unique key).
211    pub name: String,
212    /// Display title (null when unset).
213    pub title: Option<String>,
214    /// Long description (null when unset).
215    pub description: Option<String>,
216    /// Whether the project runs.
217    pub enabled: bool,
218    /// Parent project name — the inheritance link (null at the root).
219    pub parent: Option<String>,
220    /// Whether THIS project may serve as a parent (null when the
221    /// gateway did not report it).
222    pub inheritable: Option<bool>,
223}
224
225impl ProjectSummary {
226    /// Select the six stable fields from a full wire record.
227    fn from_record(record: &ProjectRecord) -> Self {
228        Self {
229            name: record.name.clone(),
230            title: record.title.clone(),
231            description: record.description.clone(),
232            enabled: record.enabled,
233            parent: record.parent.clone(),
234            inheritable: record.inheritable,
235        }
236    }
237}
238
239/// `ign project list` output model.
240#[derive(Debug, Serialize)]
241pub struct ProjectsResult {
242    /// Every runnable project.
243    pub projects: Vec<ProjectSummary>,
244}
245
246/// `project new` flags — only provided fields ride the create body
247/// (absent = NOT SENT, Pitfall 5); `enabled` is the CLI `--disabled`
248/// flag inverted at the dispatch seam.
249#[derive(Debug, Default, Clone)]
250pub struct NewOptions {
251    /// Whether the project starts enabled.
252    pub enabled: bool,
253    /// Display title.
254    pub title: Option<String>,
255    /// Long description.
256    pub description: Option<String>,
257    /// Parent project (inheritance).
258    pub parent: Option<String>,
259    /// Whether this project may serve as a parent.
260    pub inheritable: Option<bool>,
261}
262
263/// `project set` flags — ONLY the `Some` fields ride the modify body
264/// (absent flag = don't touch — Pitfall 5's modify half).
265#[derive(Debug, Default, Clone)]
266pub struct SetOptions {
267    /// Display title.
268    pub title: Option<String>,
269    /// Long description.
270    pub description: Option<String>,
271    /// Parent project — the inheritance move.
272    pub parent: Option<String>,
273    /// Whether the project runs.
274    pub enabled: Option<bool>,
275    /// Whether this project may serve as a parent.
276    pub inheritable: Option<bool>,
277}
278
279impl SetOptions {
280    /// Which fields this set touches, in flag order — the human
281    /// renderer's `set <fields> on <name>` line.
282    fn fields_set(&self) -> Vec<String> {
283        let mut fields = Vec::new();
284        if self.title.is_some() {
285            fields.push("title".to_string());
286        }
287        if self.description.is_some() {
288            fields.push("description".to_string());
289        }
290        if self.parent.is_some() {
291            fields.push("parent".to_string());
292        }
293        if self.enabled.is_some() {
294            fields.push("enabled".to_string());
295        }
296        if self.inheritable.is_some() {
297            fields.push("inheritable".to_string());
298        }
299        fields
300    }
301}
302
303/// `ign project copy` output model: the source plus the destination's
304/// read-back record (flat in JSON).
305#[derive(Debug, Serialize)]
306pub struct ProjectCopyResult {
307    /// The source name.
308    pub from: String,
309    /// The destination's read-back record.
310    #[serde(flatten)]
311    pub project: ProjectSummary,
312}
313
314/// `ign project rename` output model: previous name plus the renamed
315/// project's read-back record (flat).
316#[derive(Debug, Serialize)]
317pub struct ProjectRenameResult {
318    /// The name before the rename.
319    pub previous_name: String,
320    /// The renamed project's read-back record.
321    #[serde(flatten)]
322    pub project: ProjectSummary,
323}
324
325/// `ign project set` output model: the read-back record (flat, the
326/// stable agent shape) plus which fields this set touched —
327/// display-only, serde-skipped so it NEVER appears in JSON.
328#[derive(Debug, Serialize)]
329pub struct ProjectSetResult {
330    /// The fields this set touched (human rendering only).
331    #[serde(skip)]
332    pub fields: Vec<String>,
333    /// The post-set read-back record.
334    #[serde(flatten)]
335    pub project: ProjectSummary,
336}
337
338/// `ign project delete` output model.
339#[derive(Debug, Serialize)]
340pub struct ProjectDeleteResult {
341    /// The deleted project's name.
342    pub deleted: String,
343}
344
345/// `ign project export` output model: `{project, file, bytes, scope}`
346/// — the FILE is the artifact; stdout stays data-only.
347#[derive(Debug, Serialize)]
348pub struct ExportResult {
349    /// The exported project's name.
350    pub project: String,
351    /// Path of the file written (the `-o` value, or the resolved
352    /// default name).
353    pub file: String,
354    /// Bytes streamed to disk (chunk-counted).
355    pub bytes: u64,
356    /// What the ZIP does and does not contain (roadmap criterion 4).
357    pub scope: ExportScope,
358}
359
360/// `ign project import` output model: `{name, collision_policy,
361/// bytes, scope, outcome}` — `outcome` is the opaque server answer
362/// (an object when JSON, else the success fallback).
363#[derive(Debug, Serialize)]
364pub struct ImportResult {
365    /// The name imported under.
366    pub name: String,
367    /// The policy that ran (`abort` | `overwrite`).
368    pub collision_policy: String,
369    /// Bytes uploaded.
370    pub bytes: usize,
371    /// What the ZIP does and does not contain — the SAME consts as
372    /// export's, so the pair never drifts.
373    pub scope: ExportScope,
374    /// The server's opaque answer.
375    pub outcome: serde_json::Value,
376}
377
378// ---- Cross-gateway diff & sync (07-01, SYNC-01/02) -----------------------
379//
380// The promotion pair: see exactly what differs between two gateways'
381// copy of a project, then push selected resources across. Both
382// orchestrate over TWO `GatewayApi` handles (source A, target B) and
383// ride the pure diff engine in [`crate::client::resources`]
384// (normalized member compare — the volatility guard) plus the 05-02
385// surgery helpers (replace_member's descriptor-merge landing rules
386// ride free).
387
388/// One `project.json` semantic-field difference — `(field, a, b)`
389/// surfaced as named keys (the flat agent shape).
390#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
391pub struct ProjectMetaDelta {
392    /// The compared field (`title` | `enabled` | `parent`).
393    pub field: String,
394    /// Profile A's value (stringified; `null` when absent).
395    pub a: String,
396    /// Profile B's value (stringified; `null` when absent).
397    pub b: String,
398}
399
400/// `ign project diff` output model — the flat agent shape, ALL keys
401/// always. `scope` is the literal `"project"` (the scope-honesty
402/// mandate: tag providers live on a different seam, README documents
403/// the promotion pipe); `profile_a`/`profile_b` ride the DATA while
404/// the envelope keeps its single active-profile field (the frozen
405/// one-field envelope).
406#[derive(Debug, Serialize)]
407pub struct ProjectDiffResult {
408    /// Always `"project"` — the diff's scope contract.
409    pub scope: &'static str,
410    /// The baseline profile (A).
411    pub profile_a: String,
412    /// The compared profile (B — statuses are B-relative-to-A).
413    pub profile_b: String,
414    /// The project compared.
415    pub project: String,
416    /// Root `project.json` semantic-field differences (title/enabled/
417    /// parent) — empty when none.
418    pub project_meta: Vec<ProjectMetaDelta>,
419    /// The four member counts.
420    pub summary: crate::client::resources::DiffSummary,
421    /// One row per resource member, path-sorted.
422    pub entries: Vec<crate::client::resources::MemberDiffEntry>,
423}
424
425/// `ign project diff A B --project NAME` — export both sides (A
426/// first), run the normalized member compare plus the project.json
427/// meta delta. A missing project on either side surfaces through
428/// export's existing not-found path; the same profile twice is a
429/// usage-class refusal (exit 2) before any network I/O.
430pub async fn project_diff(
431    api_a: &dyn GatewayApi,
432    api_b: &dyn GatewayApi,
433    project: &str,
434    profile_a: &str,
435    profile_b: &str,
436) -> Result<ProjectDiffResult, CoreError> {
437    if profile_a == profile_b {
438        return Err(CoreError::InvalidInput {
439            reason: "diffing a profile against itself is a no-op — name two \
440                     different profiles"
441                .to_string(),
442        });
443    }
444    let zip_a = crate::actions::resources::export_zip_bytes(api_a, project).await?;
445    let zip_b = crate::actions::resources::export_zip_bytes(api_b, project).await?;
446    let diff = crate::client::resources::diff_members(&zip_a, &zip_b)?;
447    let project_meta = crate::client::resources::project_meta_delta(&zip_a, &zip_b)?
448        .into_iter()
449        .map(|(field, a, b)| ProjectMetaDelta { field, a, b })
450        .collect();
451    Ok(ProjectDiffResult {
452        scope: "project",
453        profile_a: profile_a.to_string(),
454        profile_b: profile_b.to_string(),
455        project: project.to_string(),
456        project_meta,
457        summary: diff.summary,
458        entries: diff.entries,
459    })
460}
461
462/// What `project sync` promotes from A into B (07-01, SYNC-02) — at
463/// least one half is required (the CLI validates pre-resolution; the
464/// action re-validates for its other callers).
465#[derive(Debug, Default, Clone)]
466pub struct SyncSelection {
467    /// Explicit `--resource` user paths (repeatable; combines with
468    /// `all_changed`).
469    pub resources: Vec<String>,
470    /// `--all-changed`: take the diff's `added`+`changed` paths —
471    /// never `removed` (deletion is the separate `--delete`
472    /// opt-in's job).
473    pub all_changed: bool,
474}
475
476/// `ign project sync` output model — the flat agent shape, ALL keys
477/// always (empty vecs when none). Direction is ALWAYS explicit A→B
478/// (source A, target B).
479#[derive(Debug, Serialize)]
480pub struct ProjectSyncResult {
481    /// Always `"project"` — the sync's scope contract.
482    pub scope: &'static str,
483    /// The source profile (A).
484    pub profile_a: String,
485    /// The target profile (B).
486    pub profile_b: String,
487    /// The project promoted.
488    pub project: String,
489    /// The user paths promoted A→B (upserted).
490    pub synced: Vec<String>,
491    /// The user paths removed from B (`--delete` only).
492    pub removed: Vec<String>,
493}
494
495/// `ign project sync A B --project NAME` — the guarded promotion.
496/// Order is the contract: export A then B → resolve the selection
497/// (explicit `--resource` paths must exist in A unless `--delete`
498/// wants them removed from B; `--all_changed` rides the diff) →
499/// splice A's member bytes into B's zip via the surgery helpers
500/// (`replace_member`'s descriptor-merge landing rules ride free —
501/// 05-07's put-new hazard is handled) → optional `remove_member`
502/// passes for deletions → `validate_import` + ONE overwrite-import
503/// into B. B's root `project.json` is never touched (only resource
504/// members splice). An EMPTY effective selection performs NO import
505/// (zero writes) and reports empty lists.
506pub async fn project_sync(
507    api_a: &dyn GatewayApi,
508    api_b: &dyn GatewayApi,
509    project: &str,
510    selection: &SyncSelection,
511    delete: bool,
512    profile_a: &str,
513    profile_b: &str,
514) -> Result<ProjectSyncResult, CoreError> {
515    if selection.resources.is_empty() && !selection.all_changed {
516        return Err(CoreError::InvalidInput {
517            reason: "sync needs a selection — pass --resource PATH (repeatable) \
518                     and/or --all-changed"
519                .to_string(),
520        });
521    }
522    let zip_a = crate::actions::resources::export_zip_bytes(api_a, project).await?;
523    let zip_b = crate::actions::resources::export_zip_bytes(api_b, project).await?;
524
525    // Resolve the selection: upserts (A's bytes land in B) and — only
526    // under --delete — removals (B loses what A no longer has).
527    let mut upserts: Vec<String> = Vec::new();
528    let mut removals: Vec<String> = Vec::new();
529    for path in &selection.resources {
530        match crate::client::resources::read_member(&zip_a, path) {
531            Ok(_) => upserts.push(path.clone()),
532            // An explicit path absent in A is a DELETION request under
533            // --delete (removed from B below); without --delete it is
534            // the missing-member shape.
535            Err(CoreError::NotFound { .. }) if delete => removals.push(path.clone()),
536            Err(other) => return Err(other),
537        }
538    }
539    if selection.all_changed {
540        // LABEL RECONCILIATION (must_haves over the plan sketch): the
541        // diff speaks B-relative-to-A (`added` = in B only, `removed`
542        // = in A only) while sync speaks A→B promotion. For A's
543        // resources to LAND in B, the upsert set is everything A has
544        // that B lacks or differs on — the diff's `removed` (A-only)
545        // and `changed` (differing) — and the `--delete` removal set
546        // is B's extras, the diff's `added` (B-only). Pushing the
547        // diff's `added` set would read members A does not have.
548        for entry in crate::client::resources::diff_members(&zip_a, &zip_b)?.entries {
549            match entry.status {
550                crate::client::resources::MemberStatus::Removed
551                | crate::client::resources::MemberStatus::Changed => {
552                    upserts.push(entry.path);
553                }
554                crate::client::resources::MemberStatus::Added if delete => {
555                    removals.push(entry.path);
556                }
557                _ => {}
558            }
559        }
560    }
561    upserts.sort();
562    upserts.dedup();
563    removals.sort();
564    removals.dedup();
565
566    // The surgery: splice A's members into B's zip, then drop the
567    // removals. replace_member's put-new descriptor rules ride free.
568    let mut surgical = zip_b;
569    for path in &upserts {
570        let bytes = crate::client::resources::read_member(&zip_a, path)?;
571        surgical = crate::client::resources::replace_member(&surgical, path, &bytes)?;
572    }
573    for path in &removals {
574        surgical = crate::client::resources::remove_member(&surgical, path)?;
575    }
576
577    // Zero-write honesty: an empty selection (nothing to upsert,
578    // nothing to remove) performs NO import — a whole-project
579    // overwrite-import of an unchanged zip is not a no-op on the
580    // gateway, so it must never fire without work to do.
581    if !upserts.is_empty() || !removals.is_empty() {
582        validate_import(&surgical)?;
583        api_b.project_import(project, surgical, true).await?;
584    }
585    Ok(ProjectSyncResult {
586        scope: "project",
587        profile_a: profile_a.to_string(),
588        profile_b: profile_b.to_string(),
589        project: project.to_string(),
590        synced: upserts,
591        removed: removals,
592    })
593}
594
595/// `ign project list` — every runnable project with inheritance info
596/// (the standard `limit=-1` UI convention).
597pub async fn projects(api: &dyn GatewayApi) -> Result<ProjectsResult, CoreError> {
598    let page = api.projects(&ListQuery::default()).await?;
599    Ok(ProjectsResult {
600        projects: page.items.iter().map(ProjectSummary::from_record).collect(),
601    })
602}
603
604/// `ign project new` — create, then `find` read-back (validates the
605/// create and fills the result; the create response body itself is
606/// unverified LOW).
607pub async fn project_new(
608    api: &dyn GatewayApi,
609    name: &str,
610    opts: &NewOptions,
611) -> Result<ProjectSummary, CoreError> {
612    let body = ProjectCreate {
613        name: name.to_string(),
614        enabled: opts.enabled,
615        title: opts.title.clone(),
616        description: opts.description.clone(),
617        parent: opts.parent.clone(),
618        inheritable: opts.inheritable,
619        default_db: None,
620        tag_provider: None,
621        user_source: None,
622    };
623    api.project_create(&body).await?;
624    let record = api.project_find(name).await?;
625    Ok(ProjectSummary::from_record(&record))
626}
627
628/// `ign project copy` — copy all resources, then `find(to)` read-back.
629pub async fn project_copy(
630    api: &dyn GatewayApi,
631    from: &str,
632    to: &str,
633) -> Result<ProjectCopyResult, CoreError> {
634    api.project_copy(from, to).await?;
635    let record = api.project_find(to).await?;
636    Ok(ProjectCopyResult {
637        from: from.to_string(),
638        project: ProjectSummary::from_record(&record),
639    })
640}
641
642/// `ign project rename` — native rename, then `find(new)` read-back.
643pub async fn project_rename(
644    api: &dyn GatewayApi,
645    old: &str,
646    new: &str,
647) -> Result<ProjectRenameResult, CoreError> {
648    api.project_rename(old, new).await?;
649    let record = api.project_find(new).await?;
650    Ok(ProjectRenameResult {
651        previous_name: old.to_string(),
652        project: ProjectSummary::from_record(&record),
653    })
654}
655
656/// `ign project set` — build the modify body from `Some`-fields ONLY
657/// (absent flag = don't touch), PUT, then read-back. `--parent` IS the
658/// inheritance move.
659pub async fn project_set(
660    api: &dyn GatewayApi,
661    name: &str,
662    opts: &SetOptions,
663) -> Result<ProjectSetResult, CoreError> {
664    let body = ProjectModify {
665        enabled: opts.enabled,
666        title: opts.title.clone(),
667        description: opts.description.clone(),
668        parent: opts.parent.clone(),
669        inheritable: opts.inheritable,
670        default_db: None,
671        tag_provider: None,
672        user_source: None,
673    };
674    api.project_modify(name, &body).await?;
675    let record = api.project_find(name).await?;
676    Ok(ProjectSetResult {
677        fields: opts.fields_set(),
678        project: ProjectSummary::from_record(&record),
679    })
680}
681
682/// `ign project delete` — the obedient arm; the `--yes` guard belongs
683/// to the CLI CALLER (it refuses pre-resolution, the LOCKED 02-03
684/// shape). The wire request always carries `confirm=true`.
685pub async fn project_delete(
686    api: &dyn GatewayApi,
687    name: &str,
688) -> Result<ProjectDeleteResult, CoreError> {
689    api.project_delete(name).await?;
690    Ok(ProjectDeleteResult {
691        deleted: name.to_string(),
692    })
693}
694
695/// `ign project export` — stream the project ZIP to disk. With `-o`
696/// the bytes land at exactly that path; without one, the stream goes
697/// to `<name>.zip.part` in the working directory and atomically
698/// renames to the SANITIZED `Content-Disposition` basename (path
699/// components stripped) or the `<name>.zip` fallback — the `.part`
700/// is removed best-effort on error, so a failed export leaves no
701/// half-written impostor.
702pub async fn project_export(
703    api: &dyn GatewayApi,
704    name: &str,
705    output: Option<&Path>,
706) -> Result<ExportResult, CoreError> {
707    let scope = ExportScope::new();
708    if let Some(out) = output {
709        let meta = api.project_export_to_file(name, out).await?;
710        return Ok(ExportResult {
711            project: name.to_string(),
712            file: out.display().to_string(),
713            bytes: meta.bytes,
714            scope,
715        });
716    }
717
718    // Default naming: stream to <fallback>.part, then rename to the
719    // disposition basename (or the fallback) once the meta arrives.
720    let fallback = format!("{}.zip", safe_fallback_stem(name));
721    let part = PathBuf::from(format!("{fallback}.part"));
722    let meta = match api.project_export_to_file(name, &part).await {
723        Ok(meta) => meta,
724        Err(err) => {
725            let _ = std::fs::remove_file(&part); // best-effort
726            return Err(err);
727        }
728    };
729    let final_name = meta
730        .filename
731        .as_deref()
732        .and_then(sanitize_basename)
733        .unwrap_or(fallback);
734    if let Err(err) = std::fs::rename(&part, &final_name) {
735        let _ = std::fs::remove_file(&part); // best-effort
736        return Err(CoreError::Internal(format!(
737            "cannot finalize export {final_name}: {err}"
738        )));
739    }
740    Ok(ExportResult {
741        project: name.to_string(),
742        file: final_name,
743        bytes: meta.bytes,
744        scope,
745    })
746}
747
748/// `ign project import` — order is the contract: magic/size guards
749/// (exit 2, zero network) → abort-policy find pre-check (`Ok` →
750/// [`CoreError::ProjectExists`] BEFORE any upload) → the raw-body
751/// upload with the policy as the wire's `overwrite` query param.
752/// Overwrite runs NO pre-check — the server is the authority — and
753/// the CLI guards it as destructive upstream of this action.
754pub async fn project_import(
755    api: &dyn GatewayApi,
756    name: &str,
757    zip: Vec<u8>,
758    policy: CollisionPolicy,
759) -> Result<ImportResult, CoreError> {
760    let bytes = zip.len();
761    let scope = ExportScope::new();
762    validate_import(&zip)?;
763    if matches!(policy, CollisionPolicy::Abort) && api.project_find(name).await.is_ok() {
764        return Err(CoreError::ProjectExists {
765            name: name.to_string(),
766            endpoint: None,
767        });
768    }
769    let overwrite = matches!(policy, CollisionPolicy::Overwrite);
770    let outcome = api.project_import(name, zip, overwrite).await?;
771    Ok(ImportResult {
772        name: name.to_string(),
773        collision_policy: policy.label().to_string(),
774        bytes,
775        scope,
776        outcome: outcome.response,
777    })
778}
779
780/// `ign project export --decode-scripts` output model (07-04,
781/// INTR-01): the DIRECTORY is the artifact — the export's members
782/// plus `<member>.<n>.py` sidecars plus the pointer manifest, ready
783/// for nvim/ignition-lint editing.
784#[derive(Debug, Serialize)]
785pub struct ExportDecodedResult {
786    /// The exported project's name.
787    pub project: String,
788    /// The directory written (the `-o` value, or `<name>-export/`).
789    pub dir: String,
790    /// File members in the export zip.
791    pub members: usize,
792    /// Scripts decoded to sidecars.
793    pub scripts_decoded: usize,
794    /// Bytes of the source export zip.
795    pub bytes: u64,
796    /// What the ZIP does and does not contain (the shared consts).
797    pub scope: ExportScope,
798}
799
800/// `ign project export NAME --decode-scripts` — buffer the export
801/// (the diff/sync seam), then decode the tree via the PURE codec:
802/// members + counter-named sidecars + `scripts-manifest.json` at the
803/// directory root. The re-encode half (`import --encode-scripts`)
804/// lives at the CLI dispatch seam — it re-zips the directory BEFORE
805/// this action's import path, which then rides verbatim
806/// (`validate_import` walks the re-zipped archive — the 05-07 guard
807/// applies free).
808pub async fn project_export_decoded(
809    api: &dyn GatewayApi,
810    name: &str,
811    out_dir: Option<&Path>,
812) -> Result<ExportDecodedResult, CoreError> {
813    let zip = crate::actions::resources::export_zip_bytes(api, name).await?;
814    let dir = match out_dir {
815        Some(dir) => dir.to_path_buf(),
816        None => PathBuf::from(format!("{}-export", safe_fallback_stem(name))),
817    };
818    let members = crate::client::scripts_codec::count_file_members(&zip)?;
819    let scripts_decoded = crate::client::scripts_codec::decode_export_tree(&zip, &dir)?;
820    Ok(ExportDecodedResult {
821        project: name.to_string(),
822        dir: dir.display().to_string(),
823        members,
824        scripts_decoded,
825        bytes: zip.len() as u64,
826        scope: ExportScope::new(),
827    })
828}
829
830#[cfg(test)]
831mod tests {
832    use super::{NewOptions, ProjectSummary, SetOptions, project_new, projects};
833    use crate::client::GatewayApi;
834    use crate::client::projects::{ProjectCreate, ProjectModify, ProjectRecord};
835    use crate::client::query::{ListEnvelope, ListMetadata};
836    use crate::error::CoreError;
837
838    use std::sync::Mutex;
839
840    /// A recording double: serves one record per find (create/copy/
841    /// rename/set read-backs), remembers every create/modify body and
842    /// every deleted name. 03-02 grows it into the export/import
843    /// double: find honors an `absent` switch (the collision
844    /// pre-check's both answers), export writes a fixture ZIP, import
845    /// records (name, bytes, overwrite) — the Task-2 action proofs key
846    /// off those recordings.
847    #[derive(Default)]
848    struct ProjectsRig {
849        creates: Mutex<Vec<ProjectCreate>>,
850        modifies: Mutex<Vec<(String, ProjectModify)>>,
851        deletes: Mutex<Vec<String>>,
852        finds: Mutex<Vec<String>>,
853        exports: Mutex<Vec<String>>,
854        imports: Mutex<Vec<(String, usize, bool)>>,
855        /// Whether `find` answers 404-NotFound instead of Ok — the
856        /// collision pre-check's two outcomes (default: the project
857        /// exists, preserving the create/copy/rename/set read-backs).
858        absent: bool,
859        /// An export-body override (07-04: the decode tests serve a
860        /// script-bearing zip; default None = the bare fixture).
861        export_body: Option<Vec<u8>>,
862    }
863
864    impl ProjectsRig {
865        /// A minimal VALID ZIP fixture (real archive — the action's
866        /// import guard walks every member since 05-07; the old
867        /// magic-bytes-plus-junk shape now refuses, correctly).
868        fn zip_fixture() -> Vec<u8> {
869            use std::io::Write as _;
870            let mut writer = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
871            let options = zip::write::SimpleFileOptions::default();
872            writer
873                .start_file("project.json", options)
874                .expect("fixture member starts");
875            writer
876                .write_all(br#"{"title":"fixture"}"#)
877                .expect("fixture member writes");
878            writer.finish().expect("fixture finalizes").into_inner()
879        }
880    }
881
882    fn record(name: &str) -> ProjectRecord {
883        ProjectRecord {
884            name: name.into(),
885            title: Some(format!("{name} title")),
886            description: None,
887            enabled: true,
888            parent: Some("Base".into()),
889            inheritable: Some(false),
890            invalid_parent: None,
891            default_db: None,
892            tag_provider: None,
893            user_source: None,
894            extra: Default::default(),
895        }
896    }
897
898    fn page(items: Vec<ProjectRecord>) -> ListEnvelope<ProjectRecord> {
899        let total = items.len() as i64;
900        ListEnvelope {
901            items,
902            metadata: ListMetadata {
903                total,
904                matching: total,
905                limit: -1,
906                offset: 0,
907            },
908        }
909    }
910
911    #[async_trait::async_trait]
912    impl GatewayApi for ProjectsRig {
913        async fn thread_dump(
914            &self,
915        ) -> Result<crate::client::threads::FormattedThreadDump, CoreError> {
916            unreachable!("not part of this action")
917        }
918        async fn thread_deadlocks(
919            &self,
920        ) -> Result<crate::client::threads::DeadlocksWire, CoreError> {
921            unreachable!("not part of this action")
922        }
923        async fn uninstall_module(
924            &self,
925            _gateway_module_id: &str,
926            _module_id: &str,
927        ) -> Result<(), CoreError> {
928            unreachable!("not part of this action")
929        }
930        async fn bundle_generate(
931            &self,
932        ) -> Result<crate::client::diagnostics::BundleStatusWire, CoreError> {
933            unreachable!("not part of this action")
934        }
935        async fn bundle_status(
936            &self,
937        ) -> Result<crate::client::diagnostics::BundleStatusWire, CoreError> {
938            unreachable!("not part of this action")
939        }
940        async fn bundle_download(
941            &self,
942            _out: &std::path::Path,
943        ) -> Result<crate::client::projects::ExportMeta, CoreError> {
944            unreachable!("not part of this action")
945        }
946        async fn tag_provider_list(
947            &self,
948            _query: &crate::client::query::ListQuery,
949        ) -> Result<
950            crate::client::query::ListEnvelope<crate::client::tags::TagProviderRecord>,
951            CoreError,
952        > {
953            unreachable!("not part of this action")
954        }
955        async fn tag_provider_find(
956            &self,
957            _name: &str,
958        ) -> Result<crate::client::tags::TagProviderRecord, CoreError> {
959            unreachable!("not part of this action")
960        }
961        async fn tag_provider_create(
962            &self,
963            _body: &[crate::client::tags::TagProviderCreate],
964        ) -> Result<(), CoreError> {
965            unreachable!("not part of this action")
966        }
967        async fn tag_provider_delete(
968            &self,
969            _name: &str,
970            _signature: &str,
971        ) -> Result<(), CoreError> {
972            unreachable!("not part of this action")
973        }
974        async fn trial_status_wire(&self) -> Result<crate::client::trial::TrialWire, CoreError> {
975            unreachable!("not part of this action")
976        }
977        async fn banners(&self) -> Result<crate::client::trial::BannerSet, CoreError> {
978            unreachable!("not part of this action")
979        }
980        async fn trial_reset_wire(&self) -> Result<crate::client::trial::TrialWire, CoreError> {
981            unreachable!("not part of this action")
982        }
983        async fn backup_download(
984            &self,
985            _out: &std::path::Path,
986            _backup_type: crate::client::backup::BackupType,
987        ) -> Result<crate::client::projects::ExportMeta, CoreError> {
988            unreachable!("not part of this action")
989        }
990        async fn backup_restore(&self, _gwbk: &std::path::Path) -> Result<(), CoreError> {
991            unreachable!("not part of this action")
992        }
993        async fn eam_task_history(
994            &self,
995            _limit: Option<u32>,
996            _search: Option<&str>,
997        ) -> Result<crate::client::query::ListEnvelope<crate::client::eam::EamHistoryItem>, CoreError>
998        {
999            unreachable!("not part of this action")
1000        }
1001        async fn eam_task_definitions(
1002            &self,
1003        ) -> Result<crate::client::query::ListEnvelope<crate::client::eam::EamTaskRecord>, CoreError>
1004        {
1005            unreachable!("not part of this action")
1006        }
1007        async fn eam_task_find(
1008            &self,
1009            _name: &str,
1010        ) -> Result<crate::client::eam::EamTaskRecord, CoreError> {
1011            unreachable!("not part of this action")
1012        }
1013        async fn eam_task_create(&self, _definition: &serde_json::Value) -> Result<(), CoreError> {
1014            unreachable!("not part of this action")
1015        }
1016        async fn eam_task_force(&self, _owner: &str, _name: &str) -> Result<(), CoreError> {
1017            unreachable!("not part of this action")
1018        }
1019        async fn eam_task_suspend(&self, _name: &str) -> Result<(), CoreError> {
1020            unreachable!("not part of this action")
1021        }
1022        async fn eam_task_resume(&self, _name: &str) -> Result<(), CoreError> {
1023            unreachable!("not part of this action")
1024        }
1025        async fn eam_task_cancel(&self, _name: &str) -> Result<(), CoreError> {
1026            unreachable!("not part of this action")
1027        }
1028        async fn eam_tasks_scheduled(
1029            &self,
1030            _running: bool,
1031        ) -> Result<Vec<crate::client::eam::EamScheduledTask>, CoreError> {
1032            unreachable!("not part of this action")
1033        }
1034        async fn eam_task_modify(
1035            &self,
1036            _definition: &serde_json::Value,
1037        ) -> Result<Option<crate::client::eam::ModifyOutcome>, CoreError> {
1038            unreachable!("not part of this action")
1039        }
1040        async fn eam_task_delete(
1041            &self,
1042            _name: &str,
1043            _signature: &str,
1044            _confirm: bool,
1045        ) -> Result<crate::client::eam::DeleteOutcome, CoreError> {
1046            unreachable!("not part of this action")
1047        }
1048        async fn api_call(
1049            &self,
1050            _call: &crate::client::apicall::ApiCallRequest,
1051        ) -> Result<crate::client::apicall::ApiCallData, CoreError> {
1052            unreachable!("not part of this action")
1053        }
1054        async fn license_status(
1055            &self,
1056        ) -> Result<crate::client::license::LicenseStatusWire, CoreError> {
1057            unreachable!("not part of this action")
1058        }
1059        async fn redundancy_status(
1060            &self,
1061        ) -> Result<crate::client::redundancy::RedundancyStatusWire, CoreError> {
1062            unreachable!("not part of this action")
1063        }
1064        async fn gan_status(&self) -> Result<crate::client::gan::GanStatusWire, CoreError> {
1065            unreachable!("not part of this action")
1066        }
1067        async fn gateway_info(&self) -> Result<crate::client::version::GatewayInfo, CoreError> {
1068            unreachable!("not part of this action")
1069        }
1070        async fn overview(&self) -> Result<crate::client::status::Overview, CoreError> {
1071            unreachable!("not part of this action")
1072        }
1073        async fn status_ping(&self) -> Result<crate::client::status::StatusPing, CoreError> {
1074            unreachable!("not part of this action")
1075        }
1076        async fn modules(
1077            &self,
1078            _quarantined: bool,
1079            _query: &crate::client::query::ListQuery,
1080        ) -> Result<ListEnvelope<crate::client::status::ModuleInfo>, CoreError> {
1081            unreachable!("not part of this action")
1082        }
1083        async fn metrics_current(
1084            &self,
1085        ) -> Result<crate::client::metrics::CurrentGauges, CoreError> {
1086            unreachable!("not part of this action")
1087        }
1088        async fn metrics_historic(
1089            &self,
1090        ) -> Result<crate::client::metrics::PerformanceCharts, CoreError> {
1091            unreachable!("not part of this action")
1092        }
1093        async fn metrics_threads(&self) -> Result<crate::client::metrics::ThreadCounts, CoreError> {
1094            unreachable!("not part of this action")
1095        }
1096        async fn designers(
1097            &self,
1098            _query: &crate::client::query::ListQuery,
1099        ) -> Result<ListEnvelope<crate::client::sessions::DesignerInfo>, CoreError> {
1100            unreachable!("not part of this action")
1101        }
1102        async fn perspective_sessions(
1103            &self,
1104            _query: &crate::client::query::ListQuery,
1105        ) -> Result<ListEnvelope<crate::client::sessions::PerspectiveSession>, CoreError> {
1106            unreachable!("not part of this action")
1107        }
1108        async fn vision_clients(
1109            &self,
1110            _query: &crate::client::query::ListQuery,
1111        ) -> Result<ListEnvelope<crate::client::sessions::VisionClient>, CoreError> {
1112            unreachable!("not part of this action")
1113        }
1114        async fn terminate_perspective_session(
1115            &self,
1116            _id: &str,
1117            _message: Option<&str>,
1118        ) -> Result<(), CoreError> {
1119            unreachable!("not part of this action")
1120        }
1121        async fn terminate_vision_client(&self, _id: &str) -> Result<(), CoreError> {
1122            unreachable!("not part of this action")
1123        }
1124        async fn prune_designer(&self, _id: &str) -> Result<(), CoreError> {
1125            unreachable!("not part of this action")
1126        }
1127        async fn database_connections(
1128            &self,
1129        ) -> Result<ListEnvelope<crate::client::connections::GatewayConnection>, CoreError>
1130        {
1131            unreachable!("not part of this action")
1132        }
1133        async fn opc_connections(
1134            &self,
1135        ) -> Result<ListEnvelope<crate::client::connections::GatewayConnection>, CoreError>
1136        {
1137            unreachable!("not part of this action")
1138        }
1139        async fn logs(
1140            &self,
1141            _filter: &crate::client::logs::LogQuery,
1142        ) -> Result<ListEnvelope<crate::client::logs::LogEntry>, CoreError> {
1143            unreachable!("not part of this action")
1144        }
1145        async fn logs_download(&self) -> Result<crate::client::logs::LogDownload, CoreError> {
1146            unreachable!("not part of this action")
1147        }
1148        async fn loggers(
1149            &self,
1150            _query: &crate::client::query::ListQuery,
1151        ) -> Result<ListEnvelope<crate::client::logs::LoggerInfo>, CoreError> {
1152            unreachable!("not part of this action")
1153        }
1154        async fn set_logger_level(&self, _logger: &str, _level: &str) -> Result<(), CoreError> {
1155            unreachable!("not part of this action")
1156        }
1157        async fn reset_logger_levels(&self) -> Result<(), CoreError> {
1158            unreachable!("not part of this action")
1159        }
1160        async fn restart(&self) -> Result<(), CoreError> {
1161            unreachable!("not part of this action")
1162        }
1163        async fn scan_projects(&self) -> Result<(), CoreError> {
1164            unreachable!("not part of this action")
1165        }
1166        async fn security_properties(
1167            &self,
1168        ) -> Result<crate::client::restart::SecurityProperties, CoreError> {
1169            unreachable!("not part of this action")
1170        }
1171        async fn webdev_route_status(&self, _route: &str) -> Result<u16, CoreError> {
1172            unreachable!("not part of this action")
1173        }
1174        async fn webdev_route_call(
1175            &self,
1176            _project: &str,
1177            _route: &str,
1178            _body: &serde_json::Value,
1179            _extra_headers: &[(&str, &str)],
1180        ) -> Result<serde_json::Value, CoreError> {
1181            unreachable!("not part of this action")
1182        }
1183        async fn webdev_route_probe(
1184            &self,
1185            _project: &str,
1186            _route: &str,
1187            _extra_headers: &[(&str, &str)],
1188        ) -> Result<crate::client::webdev::RouteProbe, CoreError> {
1189            unreachable!("not part of this action")
1190        }
1191        async fn projects(
1192            &self,
1193            _query: &crate::client::query::ListQuery,
1194        ) -> Result<ListEnvelope<ProjectRecord>, CoreError> {
1195            Ok(page(vec![record("PlantFloor"), record("Base")]))
1196        }
1197        async fn project_find(&self, name: &str) -> Result<ProjectRecord, CoreError> {
1198            self.finds.lock().unwrap().push(name.into());
1199            if self.absent {
1200                Err(CoreError::NotFound { endpoint: None })
1201            } else {
1202                Ok(record("whatever-the-rig-is-asked-for"))
1203            }
1204        }
1205        async fn project_create(&self, body: &ProjectCreate) -> Result<(), CoreError> {
1206            self.creates.lock().unwrap().push(body.clone());
1207            Ok(())
1208        }
1209        async fn project_copy(&self, _from: &str, _to: &str) -> Result<(), CoreError> {
1210            Ok(())
1211        }
1212        async fn project_rename(&self, _name: &str, _new_name: &str) -> Result<(), CoreError> {
1213            Ok(())
1214        }
1215        async fn project_modify(&self, name: &str, body: &ProjectModify) -> Result<(), CoreError> {
1216            self.modifies
1217                .lock()
1218                .unwrap()
1219                .push((name.into(), body.clone()));
1220            Ok(())
1221        }
1222        async fn project_delete(&self, name: &str) -> Result<(), CoreError> {
1223            self.deletes.lock().unwrap().push(name.into());
1224            Ok(())
1225        }
1226        async fn project_export_to_file(
1227            &self,
1228            name: &str,
1229            out: &std::path::Path,
1230        ) -> Result<crate::client::projects::ExportMeta, CoreError> {
1231            self.exports.lock().unwrap().push(name.into());
1232            let fixture = self.export_body.clone().unwrap_or_else(Self::zip_fixture);
1233            std::fs::write(out, &fixture)
1234                .map_err(|err| CoreError::Internal(format!("rig export write: {err}")))?;
1235            Ok(crate::client::projects::ExportMeta {
1236                filename: Some("rig-export.zip".into()),
1237                bytes: fixture.len() as u64,
1238                content_type: Some("application/zip".into()),
1239            })
1240        }
1241        async fn project_import(
1242            &self,
1243            name: &str,
1244            zip: Vec<u8>,
1245            overwrite: bool,
1246        ) -> Result<crate::client::projects::ImportOutcome, CoreError> {
1247            self.imports
1248                .lock()
1249                .unwrap()
1250                .push((name.into(), zip.len(), overwrite));
1251            Ok(crate::client::projects::ImportOutcome {
1252                response: serde_json::json!({"status": "success"}),
1253            })
1254        }
1255    }
1256
1257    /// THE modify-body pin: `SetOptions` with only `--title` rides the
1258    /// wire as EXACTLY `{"title":"T"}` — no other keys, no `enabled`
1259    /// clobber, no `name`.
1260    #[test]
1261    fn set_options_only_title_serializes_exactly_title() {
1262        let opts = SetOptions {
1263            title: Some("T".into()),
1264            ..Default::default()
1265        };
1266        let body = ProjectModify {
1267            enabled: opts.enabled,
1268            title: opts.title.clone(),
1269            description: opts.description.clone(),
1270            parent: opts.parent.clone(),
1271            inheritable: opts.inheritable,
1272            default_db: None,
1273            tag_provider: None,
1274            user_source: None,
1275        };
1276        assert_eq!(
1277            serde_json::to_value(&body).expect("serializes"),
1278            serde_json::json!({"title": "T"})
1279        );
1280    }
1281
1282    /// The list action selects the six stable fields (passthrough keys
1283    /// like `defaultDb` stay at the client seam, not the agent shape).
1284    #[tokio::test]
1285    async fn projects_action_selects_the_six_stable_fields() {
1286        let rig = ProjectsRig::default();
1287        let result = projects(&rig).await.expect("list");
1288        assert_eq!(result.projects.len(), 2);
1289        assert_eq!(
1290            result.projects[0],
1291            ProjectSummary {
1292                name: "PlantFloor".into(),
1293                title: Some("PlantFloor title".into()),
1294                description: None,
1295                enabled: true,
1296                parent: Some("Base".into()),
1297                inheritable: Some(false),
1298            }
1299        );
1300        // The agent shape carries ALL six keys, always.
1301        let json = serde_json::to_value(&result).expect("serialize");
1302        let mut keys: Vec<&str> = json["projects"][0]
1303            .as_object()
1304            .unwrap()
1305            .keys()
1306            .map(String::as_str)
1307            .collect();
1308        keys.sort_unstable();
1309        assert_eq!(
1310            keys,
1311            [
1312                "description",
1313                "enabled",
1314                "inheritable",
1315                "name",
1316                "parent",
1317                "title"
1318            ]
1319        );
1320    }
1321
1322    /// new = create + find read-back: the create body carries ONLY the
1323    /// provided fields, and the result is the read-back record.
1324    #[tokio::test]
1325    async fn project_new_creates_then_reads_back() {
1326        let rig = ProjectsRig::default();
1327        let opts = NewOptions {
1328            enabled: true,
1329            title: Some("T".into()),
1330            description: None,
1331            parent: Some("Base".into()),
1332            inheritable: Some(true),
1333        };
1334        let summary = project_new(&rig, "child", &opts).await.expect("new");
1335        assert_eq!(summary.name, "whatever-the-rig-is-asked-for");
1336
1337        let creates = rig.creates.lock().unwrap();
1338        assert_eq!(creates.len(), 1);
1339        assert_eq!(
1340            serde_json::to_value(&creates[0]).unwrap(),
1341            serde_json::json!({
1342                "name": "child",
1343                "enabled": true,
1344                "title": "T",
1345                "parent": "Base",
1346                "inheritable": true
1347            })
1348        );
1349    }
1350
1351    /// set = modify-with-Somes + read-back; the result records which
1352    /// fields were touched (display-only — never in JSON) and the flat
1353    /// JSON stays the six-key record shape.
1354    #[tokio::test]
1355    async fn project_set_modifies_with_somes_and_reads_back() {
1356        let rig = ProjectsRig::default();
1357        let opts = SetOptions {
1358            title: Some("T".into()),
1359            parent: Some("Base".into()),
1360            ..Default::default()
1361        };
1362        let result = super::project_set(&rig, "x", &opts).await.expect("set");
1363        assert_eq!(result.fields, vec!["title", "parent"]);
1364
1365        let modifies = rig.modifies.lock().unwrap();
1366        assert_eq!(modifies.len(), 1);
1367        assert_eq!(modifies[0].0, "x");
1368        assert_eq!(
1369            serde_json::to_value(&modifies[0].1).unwrap(),
1370            serde_json::json!({"title": "T", "parent": "Base"})
1371        );
1372
1373        // JSON: flat record keys only — `fields` is serde-skipped.
1374        let json = serde_json::to_value(&result).expect("serialize");
1375        let keys: Vec<&str> = json
1376            .as_object()
1377            .unwrap()
1378            .keys()
1379            .map(String::as_str)
1380            .collect();
1381        assert_eq!(
1382            keys,
1383            [
1384                "description",
1385                "enabled",
1386                "inheritable",
1387                "name",
1388                "parent",
1389                "title"
1390            ],
1391            "no `fields` key in the agent shape"
1392        );
1393    }
1394
1395    /// delete = the obedient arm; the guard belongs to the CLI caller.
1396    #[tokio::test]
1397    async fn project_delete_records_the_name() {
1398        let rig = ProjectsRig::default();
1399        let result = super::project_delete(&rig, "gone").await.expect("delete");
1400        assert_eq!(result.deleted, "gone");
1401        assert_eq!(*rig.deletes.lock().unwrap(), vec!["gone".to_string()]);
1402    }
1403
1404    /// THE magic-guard pin: a non-ZIP input refuses with exit 2
1405    /// `invalid_import_file` BEFORE any network I/O — neither the find
1406    /// pre-check nor the upload ever fires.
1407    #[tokio::test]
1408    async fn import_refuses_non_zip_before_any_network() {
1409        let rig = ProjectsRig::default();
1410        let err = super::project_import(
1411            &rig,
1412            "x",
1413            b"definitely not a zip".to_vec(),
1414            super::CollisionPolicy::Abort,
1415        )
1416        .await
1417        .expect_err("the magic guard refuses");
1418        assert_eq!(
1419            err.exit_code(),
1420            2,
1421            "usage class — the caller must fix the file"
1422        );
1423        assert_eq!(err.code(), "invalid_import_file");
1424        assert!(
1425            rig.finds.lock().unwrap().is_empty(),
1426            "zero pre-check calls — the guard runs first"
1427        );
1428        assert!(rig.imports.lock().unwrap().is_empty(), "zero uploads");
1429    }
1430
1431    /// THE truncated-zip pin (05-07, Rule 2): a zip with VALID magic
1432    /// but a broken tail — the live-witnessed wipe shape (8.3.3
1433    /// answers success:true changes:[] and replaces the project with
1434    /// the partial contents) — refuses `invalid_import_file` exit 2
1435    /// BEFORE any network I/O.
1436    #[tokio::test]
1437    async fn import_refuses_truncated_zip_before_any_network() {
1438        let rig = ProjectsRig::default();
1439        let truncated = {
1440            let full = ProjectsRig::zip_fixture();
1441            // Keep the magic + most of the body, cut the central
1442            // directory — exactly the partially-written-writer shape
1443            // the spike produced.
1444            let cut = full.len() - 10;
1445            full[..cut].to_vec()
1446        };
1447        let err = super::project_import(&rig, "x", truncated, super::CollisionPolicy::Overwrite)
1448            .await
1449            .expect_err("the structure guard refuses");
1450        assert_eq!(err.exit_code(), 2);
1451        assert_eq!(err.code(), "invalid_import_file");
1452        assert!(
1453            rig.finds.lock().unwrap().is_empty() && rig.imports.lock().unwrap().is_empty(),
1454            "zero network of any kind — the structure guard runs before everything"
1455        );
1456    }
1457
1458    /// The 512 MB sanity guard refuses with the same slug — checked
1459    /// through the pure size helper (no half-gigabyte allocation in a
1460    /// unit test); exactly-at-limit stays allowed.
1461    #[test]
1462    fn import_size_guard_refuses_over_512mb() {
1463        let err = super::import_size_error(super::IMPORT_MAX_BYTES + 1)
1464            .expect("one byte over the limit refuses");
1465        assert_eq!(err.exit_code(), 2);
1466        assert_eq!(err.code(), "invalid_import_file");
1467        let message = err.to_string();
1468        assert!(
1469            message.contains("512 MB"),
1470            "the reason names the limit: {message}"
1471        );
1472        assert!(
1473            super::import_size_error(super::IMPORT_MAX_BYTES).is_none(),
1474            "exactly at the limit is fine"
1475        );
1476    }
1477
1478    /// THE collision pin: abort over an existing project (find → Ok)
1479    /// refuses with `project_exists` (exit 6) BEFORE the upload, and
1480    /// the hint names BOTH the overwrite flag and its replace-semantics
1481    /// warning (Pitfall 4).
1482    #[tokio::test]
1483    async fn import_abort_over_existing_refuses_project_exists() {
1484        let rig = ProjectsRig::default(); // find → Ok: the name exists
1485        let err = super::project_import(
1486            &rig,
1487            "PlantFloor",
1488            ProjectsRig::zip_fixture(),
1489            super::CollisionPolicy::Abort,
1490        )
1491        .await
1492        .expect_err("the collision pre-check refuses");
1493        assert!(
1494            matches!(&err, CoreError::ProjectExists { name, .. } if name == "PlantFloor"),
1495            "wrong class: {err}"
1496        );
1497        assert_eq!(err.exit_code(), 6);
1498        assert_eq!(err.code(), "project_exists");
1499        let hint = err.hint().expect("hint required");
1500        assert!(
1501            hint.contains("--collision-policy overwrite"),
1502            "hint names the flag: {hint}"
1503        );
1504        assert!(
1505            hint.contains("ENTIRE project") && hint.contains("Designer-only"),
1506            "hint warns replace-not-merge: {hint}"
1507        );
1508        assert!(
1509            rig.imports.lock().unwrap().is_empty(),
1510            "the refusal happened BEFORE any upload"
1511        );
1512        assert_eq!(*rig.finds.lock().unwrap(), vec!["PlantFloor".to_string()]);
1513    }
1514
1515    /// Abort when the name is FREE: the pre-check passes (find → 404)
1516    /// and the upload fires with `overwrite=false`.
1517    #[tokio::test]
1518    async fn import_abort_when_free_uploads_without_overwrite() {
1519        let rig = ProjectsRig {
1520            absent: true,
1521            ..Default::default()
1522        };
1523        let result = super::project_import(
1524            &rig,
1525            "fresh",
1526            ProjectsRig::zip_fixture(),
1527            super::CollisionPolicy::Abort,
1528        )
1529        .await
1530        .expect("free name imports");
1531        assert_eq!(result.name, "fresh");
1532        assert_eq!(result.collision_policy, "abort");
1533        assert_eq!(result.bytes, ProjectsRig::zip_fixture().len());
1534        assert_eq!(
1535            result.scope,
1536            super::ExportScope::new(),
1537            "import carries the SAME scope consts as export"
1538        );
1539        assert_eq!(
1540            *rig.imports.lock().unwrap(),
1541            vec![("fresh".to_string(), ProjectsRig::zip_fixture().len(), false)]
1542        );
1543    }
1544
1545    /// Overwrite: NO pre-check (the server is the authority) — zero
1546    /// find calls — and the upload fires with `overwrite=true`.
1547    #[tokio::test]
1548    async fn import_overwrite_skips_pre_check_and_uploads() {
1549        let rig = ProjectsRig::default(); // find would answer Ok; it must not be asked
1550        let result = super::project_import(
1551            &rig,
1552            "PlantFloor",
1553            ProjectsRig::zip_fixture(),
1554            super::CollisionPolicy::Overwrite,
1555        )
1556        .await
1557        .expect("overwrite imports without a pre-check");
1558        assert_eq!(result.collision_policy, "overwrite");
1559        assert!(
1560            rig.finds.lock().unwrap().is_empty(),
1561            "overwrite performs ZERO pre-check calls"
1562        );
1563        assert_eq!(
1564            *rig.imports.lock().unwrap(),
1565            vec![(
1566                "PlantFloor".to_string(),
1567                ProjectsRig::zip_fixture().len(),
1568                true
1569            )]
1570        );
1571    }
1572
1573    /// Scope arrays are DATA (roadmap criterion 4): tag-providers sit
1574    /// under excludes, and the serialized shape is the two-key object
1575    /// agents key off.
1576    #[test]
1577    fn export_scope_arrays_are_data() {
1578        assert!(
1579            super::EXPORT_EXCLUDES.contains(&"tag-providers"),
1580            "the headline exclusion (tags are gateway config, not project export)"
1581        );
1582        assert!(super::EXPORT_EXCLUDES.contains(&"tags"));
1583        assert!(super::EXPORT_EXCLUDES.contains(&"udts"));
1584        assert!(super::EXPORT_INCLUDES.contains(&"views"));
1585        assert!(super::EXPORT_INCLUDES.contains(&"scripts"));
1586        assert!(super::EXPORT_INCLUDES.contains(&"named-queries"));
1587        let json = serde_json::to_value(super::ExportScope::new()).expect("scope serializes");
1588        assert_eq!(
1589            json["includes"]
1590                .as_array()
1591                .expect("includes is an array")
1592                .len(),
1593            super::EXPORT_INCLUDES.len()
1594        );
1595        assert_eq!(
1596            json["excludes"][0], "tag-providers",
1597            "declaration order is the agent-visible order"
1598        );
1599    }
1600
1601    /// Export with `-o`: the bytes land at exactly the given path and
1602    /// the result carries file/bytes/scope.
1603    #[tokio::test]
1604    async fn export_to_explicit_path_streams_and_reports() {
1605        let rig = ProjectsRig::default();
1606        let dir = tempfile::tempdir().expect("tempdir");
1607        let out = dir.path().join("proj.zip");
1608        let result = super::project_export(&rig, "My Proj", Some(&out))
1609            .await
1610            .expect("export");
1611        assert_eq!(result.project, "My Proj");
1612        assert_eq!(result.file, out.display().to_string());
1613        assert_eq!(result.bytes as usize, ProjectsRig::zip_fixture().len());
1614        assert_eq!(
1615            std::fs::read(&out).expect("file written"),
1616            ProjectsRig::zip_fixture(),
1617            "the fixture landed byte-for-byte"
1618        );
1619        assert_eq!(result.scope, super::ExportScope::new());
1620        assert_eq!(*rig.exports.lock().unwrap(), vec!["My Proj".to_string()]);
1621    }
1622
1623    /// Default-naming hygiene: a disposition basename is stripped to
1624    /// its final component (`.`/`..`/empty refuse → the caller falls
1625    /// back), and the `<name>.zip` fallback neutralizes separators.
1626    #[test]
1627    fn sanitize_basename_strips_path_components() {
1628        assert_eq!(
1629            super::sanitize_basename("MyProj-export.zip"),
1630            Some("MyProj-export.zip".to_string())
1631        );
1632        assert_eq!(
1633            super::sanitize_basename("../../etc/passwd"),
1634            Some("passwd".to_string()),
1635            "path components never survive"
1636        );
1637        assert_eq!(
1638            super::sanitize_basename(r"..\..\win\evil.zip"),
1639            Some("evil.zip".to_string())
1640        );
1641        assert_eq!(super::sanitize_basename(".."), None);
1642        assert_eq!(super::sanitize_basename("."), None);
1643        assert_eq!(super::sanitize_basename("   "), None);
1644        assert_eq!(super::safe_fallback_stem("a/b\\c"), "a_b_c");
1645    }
1646
1647    /// THE same-profile refusal (07-01): diffing a profile against
1648    /// itself is usage-class (exit 2 `invalid_input`) BEFORE any
1649    /// export fires — zero network work on the refused call.
1650    #[tokio::test]
1651    async fn project_diff_same_profile_refuses_before_any_export() {
1652        let rig = ProjectsRig::default();
1653        let err = super::project_diff(&rig, &rig, "p", "dev", "dev")
1654            .await
1655            .expect_err("the same-profile refusal");
1656        assert_eq!(err.exit_code(), 2);
1657        assert_eq!(err.code(), "invalid_input");
1658        assert!(
1659            rig.exports.lock().unwrap().is_empty(),
1660            "zero exports — the refusal leads"
1661        );
1662    }
1663
1664    /// THE selection-less sync refusal (07-01): no `--resource` and
1665    /// no `--all-changed` is usage-class exit 2 before any export.
1666    #[tokio::test]
1667    async fn project_sync_selection_less_refuses_before_any_export() {
1668        let rig = ProjectsRig::default();
1669        let err = super::project_sync(
1670            &rig,
1671            &rig,
1672            "p",
1673            &super::SyncSelection::default(),
1674            false,
1675            "a",
1676            "b",
1677        )
1678        .await
1679        .expect_err("the selection-less refusal");
1680        assert_eq!(err.exit_code(), 2);
1681        assert_eq!(err.code(), "invalid_input");
1682        assert!(rig.exports.lock().unwrap().is_empty());
1683    }
1684
1685    // ---- export --decode-scripts (07-04, INTR-01) ----
1686
1687    /// A script-bearing export zip (the codec's contract shape,
1688    /// gateway-image escapes): a view member with two embedded
1689    /// scripts + an expression value, its folder descriptor, a plain
1690    /// script-python member, and project.json.
1691    fn script_bearing_zip() -> Vec<u8> {
1692        use std::io::Write as _;
1693        let view = br#"{
1694  "scope": "G",
1695  "children": [
1696    {
1697      "type": "ia.display.label",
1698      "eventScripts": {
1699        "actionPerformed": {
1700          "config": {
1701            "script": "\tprint \u0027clicked\u0027\n\tprint \u0027done\u0027"
1702          }
1703        }
1704      }
1705    },
1706    {
1707      "type": "ia.chart",
1708      "transform": {
1709        "script": "\t\tfor i in range(3):\n\t\t\tprint i\n\t\tprint \u0027end\u0027"
1710      },
1711      "props": {
1712        "expression": "toStr({view.args.x} * 2)"
1713      }
1714    }
1715  ]
1716}"#;
1717        let mut writer = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
1718        let options = zip::write::SimpleFileOptions::default();
1719        writer.start_file("project.json", options).expect("starts");
1720        writer.write_all(br#"{"title":"T"}"#).expect("writes");
1721        writer
1722            .start_file("c/resources/views/Dash/view.json", options)
1723            .expect("starts");
1724        writer.write_all(view).expect("writes");
1725        writer
1726            .start_file("c/resources/views/Dash/resource.json", options)
1727            .expect("starts");
1728        writer
1729            .write_all(br#"{"scope":"G","version":1,"files":["view.json"]}"#)
1730            .expect("writes");
1731        writer
1732            .start_file("ignition/resources/scratch", options)
1733            .expect("starts");
1734        writer.write_all(b"print('plain')").expect("writes");
1735        writer.finish().expect("finalize").into_inner()
1736    }
1737
1738    /// The decode-export action: counts honest (members + sidecars),
1739    /// the directory carries the members + sidecars + manifest, and
1740    /// the JSON shape rides all keys in declaration order.
1741    #[tokio::test]
1742    async fn project_export_decoded_writes_the_tree() {
1743        let rig = ProjectsRig {
1744            export_body: Some(script_bearing_zip()),
1745            ..Default::default()
1746        };
1747        let dir = tempfile::tempdir().expect("tempdir");
1748        let result = super::project_export_decoded(&rig, "p", Some(dir.path()))
1749            .await
1750            .expect("decode export");
1751        assert_eq!(result.members, 4);
1752        assert_eq!(result.scripts_decoded, 2);
1753        assert_eq!(result.dir, dir.path().display().to_string());
1754        assert!(
1755            dir.path()
1756                .join("c/resources/views/Dash/view.json.1.py")
1757                .is_file()
1758        );
1759        assert!(
1760            dir.path()
1761                .join("c/resources/views/Dash/view.json.2.py")
1762                .is_file()
1763        );
1764        assert!(
1765            dir.path()
1766                .join(crate::client::scripts_codec::MANIFEST_NAME)
1767                .is_file()
1768        );
1769        // The plain script-python member rides verbatim (scope
1770        // honesty: already plain .py text — never decoded).
1771        assert_eq!(
1772            std::fs::read(dir.path().join("ignition/resources/scratch")).expect("scratch member"),
1773            b"print('plain')"
1774        );
1775        // Agent shape: all keys present (the declaration order is the
1776        // struct's — pinned by the CLI golden; a Value walk sorts).
1777        let json = serde_json::to_value(&result).expect("serialize");
1778        let mut keys: Vec<&str> = json
1779            .as_object()
1780            .unwrap()
1781            .keys()
1782            .map(String::as_str)
1783            .collect();
1784        keys.sort_unstable();
1785        assert_eq!(
1786            keys,
1787            [
1788                "bytes",
1789                "dir",
1790                "members",
1791                "project",
1792                "scope",
1793                "scripts_decoded"
1794            ]
1795        );
1796    }
1797}