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 bundle_generate(
909            &self,
910        ) -> Result<crate::client::diagnostics::BundleStatusWire, CoreError> {
911            unreachable!("not part of this action")
912        }
913        async fn bundle_status(
914            &self,
915        ) -> Result<crate::client::diagnostics::BundleStatusWire, CoreError> {
916            unreachable!("not part of this action")
917        }
918        async fn bundle_download(
919            &self,
920            _out: &std::path::Path,
921        ) -> Result<crate::client::projects::ExportMeta, CoreError> {
922            unreachable!("not part of this action")
923        }
924        async fn tag_provider_list(
925            &self,
926            _query: &crate::client::query::ListQuery,
927        ) -> Result<
928            crate::client::query::ListEnvelope<crate::client::tags::TagProviderRecord>,
929            CoreError,
930        > {
931            unreachable!("not part of this action")
932        }
933        async fn tag_provider_find(
934            &self,
935            _name: &str,
936        ) -> Result<crate::client::tags::TagProviderRecord, CoreError> {
937            unreachable!("not part of this action")
938        }
939        async fn tag_provider_create(
940            &self,
941            _body: &[crate::client::tags::TagProviderCreate],
942        ) -> Result<(), CoreError> {
943            unreachable!("not part of this action")
944        }
945        async fn tag_provider_delete(
946            &self,
947            _name: &str,
948            _signature: &str,
949        ) -> Result<(), CoreError> {
950            unreachable!("not part of this action")
951        }
952        async fn trial_status_wire(&self) -> Result<crate::client::trial::TrialWire, CoreError> {
953            unreachable!("not part of this action")
954        }
955        async fn banners(&self) -> Result<crate::client::trial::BannerSet, CoreError> {
956            unreachable!("not part of this action")
957        }
958        async fn trial_reset_wire(&self) -> Result<crate::client::trial::TrialWire, CoreError> {
959            unreachable!("not part of this action")
960        }
961        async fn backup_download(
962            &self,
963            _out: &std::path::Path,
964            _backup_type: crate::client::backup::BackupType,
965        ) -> Result<crate::client::projects::ExportMeta, CoreError> {
966            unreachable!("not part of this action")
967        }
968        async fn backup_restore(&self, _gwbk: &std::path::Path) -> Result<(), CoreError> {
969            unreachable!("not part of this action")
970        }
971        async fn eam_task_history(
972            &self,
973            _limit: Option<u32>,
974            _search: Option<&str>,
975        ) -> Result<crate::client::query::ListEnvelope<crate::client::eam::EamHistoryItem>, CoreError>
976        {
977            unreachable!("not part of this action")
978        }
979        async fn eam_task_definitions(
980            &self,
981        ) -> Result<crate::client::query::ListEnvelope<crate::client::eam::EamTaskRecord>, CoreError>
982        {
983            unreachable!("not part of this action")
984        }
985        async fn eam_task_find(
986            &self,
987            _name: &str,
988        ) -> Result<crate::client::eam::EamTaskRecord, CoreError> {
989            unreachable!("not part of this action")
990        }
991        async fn eam_task_create(&self, _definition: &serde_json::Value) -> Result<(), CoreError> {
992            unreachable!("not part of this action")
993        }
994        async fn eam_task_force(&self, _owner: &str, _name: &str) -> Result<(), CoreError> {
995            unreachable!("not part of this action")
996        }
997        async fn eam_task_suspend(&self, _name: &str) -> Result<(), CoreError> {
998            unreachable!("not part of this action")
999        }
1000        async fn eam_task_resume(&self, _name: &str) -> Result<(), CoreError> {
1001            unreachable!("not part of this action")
1002        }
1003        async fn eam_task_cancel(&self, _name: &str) -> Result<(), CoreError> {
1004            unreachable!("not part of this action")
1005        }
1006        async fn eam_tasks_scheduled(
1007            &self,
1008            _running: bool,
1009        ) -> Result<Vec<crate::client::eam::EamScheduledTask>, CoreError> {
1010            unreachable!("not part of this action")
1011        }
1012        async fn eam_task_modify(
1013            &self,
1014            _definition: &serde_json::Value,
1015        ) -> Result<Option<crate::client::eam::ModifyOutcome>, CoreError> {
1016            unreachable!("not part of this action")
1017        }
1018        async fn eam_task_delete(
1019            &self,
1020            _name: &str,
1021            _signature: &str,
1022            _confirm: bool,
1023        ) -> Result<crate::client::eam::DeleteOutcome, CoreError> {
1024            unreachable!("not part of this action")
1025        }
1026        async fn api_call(
1027            &self,
1028            _call: &crate::client::apicall::ApiCallRequest,
1029        ) -> Result<crate::client::apicall::ApiCallData, CoreError> {
1030            unreachable!("not part of this action")
1031        }
1032        async fn license_status(
1033            &self,
1034        ) -> Result<crate::client::license::LicenseStatusWire, CoreError> {
1035            unreachable!("not part of this action")
1036        }
1037        async fn redundancy_status(
1038            &self,
1039        ) -> Result<crate::client::redundancy::RedundancyStatusWire, CoreError> {
1040            unreachable!("not part of this action")
1041        }
1042        async fn gan_status(&self) -> Result<crate::client::gan::GanStatusWire, CoreError> {
1043            unreachable!("not part of this action")
1044        }
1045        async fn gateway_info(&self) -> Result<crate::client::version::GatewayInfo, CoreError> {
1046            unreachable!("not part of this action")
1047        }
1048        async fn overview(&self) -> Result<crate::client::status::Overview, CoreError> {
1049            unreachable!("not part of this action")
1050        }
1051        async fn status_ping(&self) -> Result<crate::client::status::StatusPing, CoreError> {
1052            unreachable!("not part of this action")
1053        }
1054        async fn modules(
1055            &self,
1056            _quarantined: bool,
1057            _query: &crate::client::query::ListQuery,
1058        ) -> Result<ListEnvelope<crate::client::status::ModuleInfo>, CoreError> {
1059            unreachable!("not part of this action")
1060        }
1061        async fn metrics_current(
1062            &self,
1063        ) -> Result<crate::client::metrics::CurrentGauges, CoreError> {
1064            unreachable!("not part of this action")
1065        }
1066        async fn metrics_historic(
1067            &self,
1068        ) -> Result<crate::client::metrics::PerformanceCharts, CoreError> {
1069            unreachable!("not part of this action")
1070        }
1071        async fn metrics_threads(&self) -> Result<crate::client::metrics::ThreadCounts, CoreError> {
1072            unreachable!("not part of this action")
1073        }
1074        async fn designers(
1075            &self,
1076            _query: &crate::client::query::ListQuery,
1077        ) -> Result<ListEnvelope<crate::client::sessions::DesignerInfo>, CoreError> {
1078            unreachable!("not part of this action")
1079        }
1080        async fn perspective_sessions(
1081            &self,
1082            _query: &crate::client::query::ListQuery,
1083        ) -> Result<ListEnvelope<crate::client::sessions::PerspectiveSession>, CoreError> {
1084            unreachable!("not part of this action")
1085        }
1086        async fn vision_clients(
1087            &self,
1088            _query: &crate::client::query::ListQuery,
1089        ) -> Result<ListEnvelope<crate::client::sessions::VisionClient>, CoreError> {
1090            unreachable!("not part of this action")
1091        }
1092        async fn terminate_perspective_session(
1093            &self,
1094            _id: &str,
1095            _message: Option<&str>,
1096        ) -> Result<(), CoreError> {
1097            unreachable!("not part of this action")
1098        }
1099        async fn terminate_vision_client(&self, _id: &str) -> Result<(), CoreError> {
1100            unreachable!("not part of this action")
1101        }
1102        async fn prune_designer(&self, _id: &str) -> Result<(), CoreError> {
1103            unreachable!("not part of this action")
1104        }
1105        async fn database_connections(
1106            &self,
1107        ) -> Result<ListEnvelope<crate::client::connections::GatewayConnection>, CoreError>
1108        {
1109            unreachable!("not part of this action")
1110        }
1111        async fn opc_connections(
1112            &self,
1113        ) -> Result<ListEnvelope<crate::client::connections::GatewayConnection>, CoreError>
1114        {
1115            unreachable!("not part of this action")
1116        }
1117        async fn logs(
1118            &self,
1119            _filter: &crate::client::logs::LogQuery,
1120        ) -> Result<ListEnvelope<crate::client::logs::LogEntry>, CoreError> {
1121            unreachable!("not part of this action")
1122        }
1123        async fn logs_download(&self) -> Result<crate::client::logs::LogDownload, CoreError> {
1124            unreachable!("not part of this action")
1125        }
1126        async fn loggers(
1127            &self,
1128            _query: &crate::client::query::ListQuery,
1129        ) -> Result<ListEnvelope<crate::client::logs::LoggerInfo>, CoreError> {
1130            unreachable!("not part of this action")
1131        }
1132        async fn set_logger_level(&self, _logger: &str, _level: &str) -> Result<(), CoreError> {
1133            unreachable!("not part of this action")
1134        }
1135        async fn reset_logger_levels(&self) -> Result<(), CoreError> {
1136            unreachable!("not part of this action")
1137        }
1138        async fn restart(&self) -> Result<(), CoreError> {
1139            unreachable!("not part of this action")
1140        }
1141        async fn scan_projects(&self) -> Result<(), CoreError> {
1142            unreachable!("not part of this action")
1143        }
1144        async fn security_properties(
1145            &self,
1146        ) -> Result<crate::client::restart::SecurityProperties, CoreError> {
1147            unreachable!("not part of this action")
1148        }
1149        async fn webdev_route_status(&self, _route: &str) -> Result<u16, CoreError> {
1150            unreachable!("not part of this action")
1151        }
1152        async fn webdev_route_call(
1153            &self,
1154            _project: &str,
1155            _route: &str,
1156            _body: &serde_json::Value,
1157            _extra_headers: &[(&str, &str)],
1158        ) -> Result<serde_json::Value, CoreError> {
1159            unreachable!("not part of this action")
1160        }
1161        async fn webdev_route_probe(
1162            &self,
1163            _project: &str,
1164            _route: &str,
1165            _extra_headers: &[(&str, &str)],
1166        ) -> Result<crate::client::webdev::RouteProbe, CoreError> {
1167            unreachable!("not part of this action")
1168        }
1169        async fn projects(
1170            &self,
1171            _query: &crate::client::query::ListQuery,
1172        ) -> Result<ListEnvelope<ProjectRecord>, CoreError> {
1173            Ok(page(vec![record("PlantFloor"), record("Base")]))
1174        }
1175        async fn project_find(&self, name: &str) -> Result<ProjectRecord, CoreError> {
1176            self.finds.lock().unwrap().push(name.into());
1177            if self.absent {
1178                Err(CoreError::NotFound { endpoint: None })
1179            } else {
1180                Ok(record("whatever-the-rig-is-asked-for"))
1181            }
1182        }
1183        async fn project_create(&self, body: &ProjectCreate) -> Result<(), CoreError> {
1184            self.creates.lock().unwrap().push(body.clone());
1185            Ok(())
1186        }
1187        async fn project_copy(&self, _from: &str, _to: &str) -> Result<(), CoreError> {
1188            Ok(())
1189        }
1190        async fn project_rename(&self, _name: &str, _new_name: &str) -> Result<(), CoreError> {
1191            Ok(())
1192        }
1193        async fn project_modify(&self, name: &str, body: &ProjectModify) -> Result<(), CoreError> {
1194            self.modifies
1195                .lock()
1196                .unwrap()
1197                .push((name.into(), body.clone()));
1198            Ok(())
1199        }
1200        async fn project_delete(&self, name: &str) -> Result<(), CoreError> {
1201            self.deletes.lock().unwrap().push(name.into());
1202            Ok(())
1203        }
1204        async fn project_export_to_file(
1205            &self,
1206            name: &str,
1207            out: &std::path::Path,
1208        ) -> Result<crate::client::projects::ExportMeta, CoreError> {
1209            self.exports.lock().unwrap().push(name.into());
1210            let fixture = self.export_body.clone().unwrap_or_else(Self::zip_fixture);
1211            std::fs::write(out, &fixture)
1212                .map_err(|err| CoreError::Internal(format!("rig export write: {err}")))?;
1213            Ok(crate::client::projects::ExportMeta {
1214                filename: Some("rig-export.zip".into()),
1215                bytes: fixture.len() as u64,
1216                content_type: Some("application/zip".into()),
1217            })
1218        }
1219        async fn project_import(
1220            &self,
1221            name: &str,
1222            zip: Vec<u8>,
1223            overwrite: bool,
1224        ) -> Result<crate::client::projects::ImportOutcome, CoreError> {
1225            self.imports
1226                .lock()
1227                .unwrap()
1228                .push((name.into(), zip.len(), overwrite));
1229            Ok(crate::client::projects::ImportOutcome {
1230                response: serde_json::json!({"status": "success"}),
1231            })
1232        }
1233    }
1234
1235    /// THE modify-body pin: `SetOptions` with only `--title` rides the
1236    /// wire as EXACTLY `{"title":"T"}` — no other keys, no `enabled`
1237    /// clobber, no `name`.
1238    #[test]
1239    fn set_options_only_title_serializes_exactly_title() {
1240        let opts = SetOptions {
1241            title: Some("T".into()),
1242            ..Default::default()
1243        };
1244        let body = ProjectModify {
1245            enabled: opts.enabled,
1246            title: opts.title.clone(),
1247            description: opts.description.clone(),
1248            parent: opts.parent.clone(),
1249            inheritable: opts.inheritable,
1250            default_db: None,
1251            tag_provider: None,
1252            user_source: None,
1253        };
1254        assert_eq!(
1255            serde_json::to_value(&body).expect("serializes"),
1256            serde_json::json!({"title": "T"})
1257        );
1258    }
1259
1260    /// The list action selects the six stable fields (passthrough keys
1261    /// like `defaultDb` stay at the client seam, not the agent shape).
1262    #[tokio::test]
1263    async fn projects_action_selects_the_six_stable_fields() {
1264        let rig = ProjectsRig::default();
1265        let result = projects(&rig).await.expect("list");
1266        assert_eq!(result.projects.len(), 2);
1267        assert_eq!(
1268            result.projects[0],
1269            ProjectSummary {
1270                name: "PlantFloor".into(),
1271                title: Some("PlantFloor title".into()),
1272                description: None,
1273                enabled: true,
1274                parent: Some("Base".into()),
1275                inheritable: Some(false),
1276            }
1277        );
1278        // The agent shape carries ALL six keys, always.
1279        let json = serde_json::to_value(&result).expect("serialize");
1280        let mut keys: Vec<&str> = json["projects"][0]
1281            .as_object()
1282            .unwrap()
1283            .keys()
1284            .map(String::as_str)
1285            .collect();
1286        keys.sort_unstable();
1287        assert_eq!(
1288            keys,
1289            [
1290                "description",
1291                "enabled",
1292                "inheritable",
1293                "name",
1294                "parent",
1295                "title"
1296            ]
1297        );
1298    }
1299
1300    /// new = create + find read-back: the create body carries ONLY the
1301    /// provided fields, and the result is the read-back record.
1302    #[tokio::test]
1303    async fn project_new_creates_then_reads_back() {
1304        let rig = ProjectsRig::default();
1305        let opts = NewOptions {
1306            enabled: true,
1307            title: Some("T".into()),
1308            description: None,
1309            parent: Some("Base".into()),
1310            inheritable: Some(true),
1311        };
1312        let summary = project_new(&rig, "child", &opts).await.expect("new");
1313        assert_eq!(summary.name, "whatever-the-rig-is-asked-for");
1314
1315        let creates = rig.creates.lock().unwrap();
1316        assert_eq!(creates.len(), 1);
1317        assert_eq!(
1318            serde_json::to_value(&creates[0]).unwrap(),
1319            serde_json::json!({
1320                "name": "child",
1321                "enabled": true,
1322                "title": "T",
1323                "parent": "Base",
1324                "inheritable": true
1325            })
1326        );
1327    }
1328
1329    /// set = modify-with-Somes + read-back; the result records which
1330    /// fields were touched (display-only — never in JSON) and the flat
1331    /// JSON stays the six-key record shape.
1332    #[tokio::test]
1333    async fn project_set_modifies_with_somes_and_reads_back() {
1334        let rig = ProjectsRig::default();
1335        let opts = SetOptions {
1336            title: Some("T".into()),
1337            parent: Some("Base".into()),
1338            ..Default::default()
1339        };
1340        let result = super::project_set(&rig, "x", &opts).await.expect("set");
1341        assert_eq!(result.fields, vec!["title", "parent"]);
1342
1343        let modifies = rig.modifies.lock().unwrap();
1344        assert_eq!(modifies.len(), 1);
1345        assert_eq!(modifies[0].0, "x");
1346        assert_eq!(
1347            serde_json::to_value(&modifies[0].1).unwrap(),
1348            serde_json::json!({"title": "T", "parent": "Base"})
1349        );
1350
1351        // JSON: flat record keys only — `fields` is serde-skipped.
1352        let json = serde_json::to_value(&result).expect("serialize");
1353        let keys: Vec<&str> = json
1354            .as_object()
1355            .unwrap()
1356            .keys()
1357            .map(String::as_str)
1358            .collect();
1359        assert_eq!(
1360            keys,
1361            [
1362                "description",
1363                "enabled",
1364                "inheritable",
1365                "name",
1366                "parent",
1367                "title"
1368            ],
1369            "no `fields` key in the agent shape"
1370        );
1371    }
1372
1373    /// delete = the obedient arm; the guard belongs to the CLI caller.
1374    #[tokio::test]
1375    async fn project_delete_records_the_name() {
1376        let rig = ProjectsRig::default();
1377        let result = super::project_delete(&rig, "gone").await.expect("delete");
1378        assert_eq!(result.deleted, "gone");
1379        assert_eq!(*rig.deletes.lock().unwrap(), vec!["gone".to_string()]);
1380    }
1381
1382    /// THE magic-guard pin: a non-ZIP input refuses with exit 2
1383    /// `invalid_import_file` BEFORE any network I/O — neither the find
1384    /// pre-check nor the upload ever fires.
1385    #[tokio::test]
1386    async fn import_refuses_non_zip_before_any_network() {
1387        let rig = ProjectsRig::default();
1388        let err = super::project_import(
1389            &rig,
1390            "x",
1391            b"definitely not a zip".to_vec(),
1392            super::CollisionPolicy::Abort,
1393        )
1394        .await
1395        .expect_err("the magic guard refuses");
1396        assert_eq!(
1397            err.exit_code(),
1398            2,
1399            "usage class — the caller must fix the file"
1400        );
1401        assert_eq!(err.code(), "invalid_import_file");
1402        assert!(
1403            rig.finds.lock().unwrap().is_empty(),
1404            "zero pre-check calls — the guard runs first"
1405        );
1406        assert!(rig.imports.lock().unwrap().is_empty(), "zero uploads");
1407    }
1408
1409    /// THE truncated-zip pin (05-07, Rule 2): a zip with VALID magic
1410    /// but a broken tail — the live-witnessed wipe shape (8.3.3
1411    /// answers success:true changes:[] and replaces the project with
1412    /// the partial contents) — refuses `invalid_import_file` exit 2
1413    /// BEFORE any network I/O.
1414    #[tokio::test]
1415    async fn import_refuses_truncated_zip_before_any_network() {
1416        let rig = ProjectsRig::default();
1417        let truncated = {
1418            let full = ProjectsRig::zip_fixture();
1419            // Keep the magic + most of the body, cut the central
1420            // directory — exactly the partially-written-writer shape
1421            // the spike produced.
1422            let cut = full.len() - 10;
1423            full[..cut].to_vec()
1424        };
1425        let err = super::project_import(&rig, "x", truncated, super::CollisionPolicy::Overwrite)
1426            .await
1427            .expect_err("the structure guard refuses");
1428        assert_eq!(err.exit_code(), 2);
1429        assert_eq!(err.code(), "invalid_import_file");
1430        assert!(
1431            rig.finds.lock().unwrap().is_empty() && rig.imports.lock().unwrap().is_empty(),
1432            "zero network of any kind — the structure guard runs before everything"
1433        );
1434    }
1435
1436    /// The 512 MB sanity guard refuses with the same slug — checked
1437    /// through the pure size helper (no half-gigabyte allocation in a
1438    /// unit test); exactly-at-limit stays allowed.
1439    #[test]
1440    fn import_size_guard_refuses_over_512mb() {
1441        let err = super::import_size_error(super::IMPORT_MAX_BYTES + 1)
1442            .expect("one byte over the limit refuses");
1443        assert_eq!(err.exit_code(), 2);
1444        assert_eq!(err.code(), "invalid_import_file");
1445        let message = err.to_string();
1446        assert!(
1447            message.contains("512 MB"),
1448            "the reason names the limit: {message}"
1449        );
1450        assert!(
1451            super::import_size_error(super::IMPORT_MAX_BYTES).is_none(),
1452            "exactly at the limit is fine"
1453        );
1454    }
1455
1456    /// THE collision pin: abort over an existing project (find → Ok)
1457    /// refuses with `project_exists` (exit 6) BEFORE the upload, and
1458    /// the hint names BOTH the overwrite flag and its replace-semantics
1459    /// warning (Pitfall 4).
1460    #[tokio::test]
1461    async fn import_abort_over_existing_refuses_project_exists() {
1462        let rig = ProjectsRig::default(); // find → Ok: the name exists
1463        let err = super::project_import(
1464            &rig,
1465            "PlantFloor",
1466            ProjectsRig::zip_fixture(),
1467            super::CollisionPolicy::Abort,
1468        )
1469        .await
1470        .expect_err("the collision pre-check refuses");
1471        assert!(
1472            matches!(&err, CoreError::ProjectExists { name, .. } if name == "PlantFloor"),
1473            "wrong class: {err}"
1474        );
1475        assert_eq!(err.exit_code(), 6);
1476        assert_eq!(err.code(), "project_exists");
1477        let hint = err.hint().expect("hint required");
1478        assert!(
1479            hint.contains("--collision-policy overwrite"),
1480            "hint names the flag: {hint}"
1481        );
1482        assert!(
1483            hint.contains("ENTIRE project") && hint.contains("Designer-only"),
1484            "hint warns replace-not-merge: {hint}"
1485        );
1486        assert!(
1487            rig.imports.lock().unwrap().is_empty(),
1488            "the refusal happened BEFORE any upload"
1489        );
1490        assert_eq!(*rig.finds.lock().unwrap(), vec!["PlantFloor".to_string()]);
1491    }
1492
1493    /// Abort when the name is FREE: the pre-check passes (find → 404)
1494    /// and the upload fires with `overwrite=false`.
1495    #[tokio::test]
1496    async fn import_abort_when_free_uploads_without_overwrite() {
1497        let rig = ProjectsRig {
1498            absent: true,
1499            ..Default::default()
1500        };
1501        let result = super::project_import(
1502            &rig,
1503            "fresh",
1504            ProjectsRig::zip_fixture(),
1505            super::CollisionPolicy::Abort,
1506        )
1507        .await
1508        .expect("free name imports");
1509        assert_eq!(result.name, "fresh");
1510        assert_eq!(result.collision_policy, "abort");
1511        assert_eq!(result.bytes, ProjectsRig::zip_fixture().len());
1512        assert_eq!(
1513            result.scope,
1514            super::ExportScope::new(),
1515            "import carries the SAME scope consts as export"
1516        );
1517        assert_eq!(
1518            *rig.imports.lock().unwrap(),
1519            vec![("fresh".to_string(), ProjectsRig::zip_fixture().len(), false)]
1520        );
1521    }
1522
1523    /// Overwrite: NO pre-check (the server is the authority) — zero
1524    /// find calls — and the upload fires with `overwrite=true`.
1525    #[tokio::test]
1526    async fn import_overwrite_skips_pre_check_and_uploads() {
1527        let rig = ProjectsRig::default(); // find would answer Ok; it must not be asked
1528        let result = super::project_import(
1529            &rig,
1530            "PlantFloor",
1531            ProjectsRig::zip_fixture(),
1532            super::CollisionPolicy::Overwrite,
1533        )
1534        .await
1535        .expect("overwrite imports without a pre-check");
1536        assert_eq!(result.collision_policy, "overwrite");
1537        assert!(
1538            rig.finds.lock().unwrap().is_empty(),
1539            "overwrite performs ZERO pre-check calls"
1540        );
1541        assert_eq!(
1542            *rig.imports.lock().unwrap(),
1543            vec![(
1544                "PlantFloor".to_string(),
1545                ProjectsRig::zip_fixture().len(),
1546                true
1547            )]
1548        );
1549    }
1550
1551    /// Scope arrays are DATA (roadmap criterion 4): tag-providers sit
1552    /// under excludes, and the serialized shape is the two-key object
1553    /// agents key off.
1554    #[test]
1555    fn export_scope_arrays_are_data() {
1556        assert!(
1557            super::EXPORT_EXCLUDES.contains(&"tag-providers"),
1558            "the headline exclusion (tags are gateway config, not project export)"
1559        );
1560        assert!(super::EXPORT_EXCLUDES.contains(&"tags"));
1561        assert!(super::EXPORT_EXCLUDES.contains(&"udts"));
1562        assert!(super::EXPORT_INCLUDES.contains(&"views"));
1563        assert!(super::EXPORT_INCLUDES.contains(&"scripts"));
1564        assert!(super::EXPORT_INCLUDES.contains(&"named-queries"));
1565        let json = serde_json::to_value(super::ExportScope::new()).expect("scope serializes");
1566        assert_eq!(
1567            json["includes"]
1568                .as_array()
1569                .expect("includes is an array")
1570                .len(),
1571            super::EXPORT_INCLUDES.len()
1572        );
1573        assert_eq!(
1574            json["excludes"][0], "tag-providers",
1575            "declaration order is the agent-visible order"
1576        );
1577    }
1578
1579    /// Export with `-o`: the bytes land at exactly the given path and
1580    /// the result carries file/bytes/scope.
1581    #[tokio::test]
1582    async fn export_to_explicit_path_streams_and_reports() {
1583        let rig = ProjectsRig::default();
1584        let dir = tempfile::tempdir().expect("tempdir");
1585        let out = dir.path().join("proj.zip");
1586        let result = super::project_export(&rig, "My Proj", Some(&out))
1587            .await
1588            .expect("export");
1589        assert_eq!(result.project, "My Proj");
1590        assert_eq!(result.file, out.display().to_string());
1591        assert_eq!(result.bytes as usize, ProjectsRig::zip_fixture().len());
1592        assert_eq!(
1593            std::fs::read(&out).expect("file written"),
1594            ProjectsRig::zip_fixture(),
1595            "the fixture landed byte-for-byte"
1596        );
1597        assert_eq!(result.scope, super::ExportScope::new());
1598        assert_eq!(*rig.exports.lock().unwrap(), vec!["My Proj".to_string()]);
1599    }
1600
1601    /// Default-naming hygiene: a disposition basename is stripped to
1602    /// its final component (`.`/`..`/empty refuse → the caller falls
1603    /// back), and the `<name>.zip` fallback neutralizes separators.
1604    #[test]
1605    fn sanitize_basename_strips_path_components() {
1606        assert_eq!(
1607            super::sanitize_basename("MyProj-export.zip"),
1608            Some("MyProj-export.zip".to_string())
1609        );
1610        assert_eq!(
1611            super::sanitize_basename("../../etc/passwd"),
1612            Some("passwd".to_string()),
1613            "path components never survive"
1614        );
1615        assert_eq!(
1616            super::sanitize_basename(r"..\..\win\evil.zip"),
1617            Some("evil.zip".to_string())
1618        );
1619        assert_eq!(super::sanitize_basename(".."), None);
1620        assert_eq!(super::sanitize_basename("."), None);
1621        assert_eq!(super::sanitize_basename("   "), None);
1622        assert_eq!(super::safe_fallback_stem("a/b\\c"), "a_b_c");
1623    }
1624
1625    /// THE same-profile refusal (07-01): diffing a profile against
1626    /// itself is usage-class (exit 2 `invalid_input`) BEFORE any
1627    /// export fires — zero network work on the refused call.
1628    #[tokio::test]
1629    async fn project_diff_same_profile_refuses_before_any_export() {
1630        let rig = ProjectsRig::default();
1631        let err = super::project_diff(&rig, &rig, "p", "dev", "dev")
1632            .await
1633            .expect_err("the same-profile refusal");
1634        assert_eq!(err.exit_code(), 2);
1635        assert_eq!(err.code(), "invalid_input");
1636        assert!(
1637            rig.exports.lock().unwrap().is_empty(),
1638            "zero exports — the refusal leads"
1639        );
1640    }
1641
1642    /// THE selection-less sync refusal (07-01): no `--resource` and
1643    /// no `--all-changed` is usage-class exit 2 before any export.
1644    #[tokio::test]
1645    async fn project_sync_selection_less_refuses_before_any_export() {
1646        let rig = ProjectsRig::default();
1647        let err = super::project_sync(
1648            &rig,
1649            &rig,
1650            "p",
1651            &super::SyncSelection::default(),
1652            false,
1653            "a",
1654            "b",
1655        )
1656        .await
1657        .expect_err("the selection-less refusal");
1658        assert_eq!(err.exit_code(), 2);
1659        assert_eq!(err.code(), "invalid_input");
1660        assert!(rig.exports.lock().unwrap().is_empty());
1661    }
1662
1663    // ---- export --decode-scripts (07-04, INTR-01) ----
1664
1665    /// A script-bearing export zip (the codec's contract shape,
1666    /// gateway-image escapes): a view member with two embedded
1667    /// scripts + an expression value, its folder descriptor, a plain
1668    /// script-python member, and project.json.
1669    fn script_bearing_zip() -> Vec<u8> {
1670        use std::io::Write as _;
1671        let view = br#"{
1672  "scope": "G",
1673  "children": [
1674    {
1675      "type": "ia.display.label",
1676      "eventScripts": {
1677        "actionPerformed": {
1678          "config": {
1679            "script": "\tprint \u0027clicked\u0027\n\tprint \u0027done\u0027"
1680          }
1681        }
1682      }
1683    },
1684    {
1685      "type": "ia.chart",
1686      "transform": {
1687        "script": "\t\tfor i in range(3):\n\t\t\tprint i\n\t\tprint \u0027end\u0027"
1688      },
1689      "props": {
1690        "expression": "toStr({view.args.x} * 2)"
1691      }
1692    }
1693  ]
1694}"#;
1695        let mut writer = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
1696        let options = zip::write::SimpleFileOptions::default();
1697        writer.start_file("project.json", options).expect("starts");
1698        writer.write_all(br#"{"title":"T"}"#).expect("writes");
1699        writer
1700            .start_file("c/resources/views/Dash/view.json", options)
1701            .expect("starts");
1702        writer.write_all(view).expect("writes");
1703        writer
1704            .start_file("c/resources/views/Dash/resource.json", options)
1705            .expect("starts");
1706        writer
1707            .write_all(br#"{"scope":"G","version":1,"files":["view.json"]}"#)
1708            .expect("writes");
1709        writer
1710            .start_file("ignition/resources/scratch", options)
1711            .expect("starts");
1712        writer.write_all(b"print('plain')").expect("writes");
1713        writer.finish().expect("finalize").into_inner()
1714    }
1715
1716    /// The decode-export action: counts honest (members + sidecars),
1717    /// the directory carries the members + sidecars + manifest, and
1718    /// the JSON shape rides all keys in declaration order.
1719    #[tokio::test]
1720    async fn project_export_decoded_writes_the_tree() {
1721        let rig = ProjectsRig {
1722            export_body: Some(script_bearing_zip()),
1723            ..Default::default()
1724        };
1725        let dir = tempfile::tempdir().expect("tempdir");
1726        let result = super::project_export_decoded(&rig, "p", Some(dir.path()))
1727            .await
1728            .expect("decode export");
1729        assert_eq!(result.members, 4);
1730        assert_eq!(result.scripts_decoded, 2);
1731        assert_eq!(result.dir, dir.path().display().to_string());
1732        assert!(
1733            dir.path()
1734                .join("c/resources/views/Dash/view.json.1.py")
1735                .is_file()
1736        );
1737        assert!(
1738            dir.path()
1739                .join("c/resources/views/Dash/view.json.2.py")
1740                .is_file()
1741        );
1742        assert!(
1743            dir.path()
1744                .join(crate::client::scripts_codec::MANIFEST_NAME)
1745                .is_file()
1746        );
1747        // The plain script-python member rides verbatim (scope
1748        // honesty: already plain .py text — never decoded).
1749        assert_eq!(
1750            std::fs::read(dir.path().join("ignition/resources/scratch")).expect("scratch member"),
1751            b"print('plain')"
1752        );
1753        // Agent shape: all keys present (the declaration order is the
1754        // struct's — pinned by the CLI golden; a Value walk sorts).
1755        let json = serde_json::to_value(&result).expect("serialize");
1756        let mut keys: Vec<&str> = json
1757            .as_object()
1758            .unwrap()
1759            .keys()
1760            .map(String::as_str)
1761            .collect();
1762        keys.sort_unstable();
1763        assert_eq!(
1764            keys,
1765            [
1766                "bytes",
1767                "dir",
1768                "members",
1769                "project",
1770                "scope",
1771                "scripts_decoded"
1772            ]
1773        );
1774    }
1775}