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