Skip to main content

dpm_model/
asset.rs

1use crate::{AssetId, Key, Plan, ValidationError, validation::invalid};
2use serde::{Deserialize, Serialize};
3use std::collections::BTreeSet;
4
5/// Shared asset identity independent of local paths and remote addresses.
6#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
7#[serde(deny_unknown_fields)]
8pub struct WorkspaceAsset {
9    /// Stable identity matching its map key.
10    pub id: AssetId,
11    /// Human-readable key unique within the workspace's assets.
12    pub key: Key,
13    /// Human-readable asset label.
14    pub label: String,
15    /// Kind of asset; none grants evidence or task completion.
16    pub kind: AssetKind,
17}
18
19/// WorkspaceAsset category supporting both code and non-code work.
20#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
21#[serde(deny_unknown_fields)]
22pub enum AssetKind {
23    /// A repository; remote URLs are attributes, not its identity.
24    GitRepository {
25        /// Public remote addresses without credentials.
26        remotes: Vec<String>,
27    },
28    /// A filesystem collection with device-local location bindings.
29    Folder,
30    /// Documents used independently of a Git repository.
31    DocumentCollection,
32    /// Author-defined asset category.
33    Other(String),
34}
35
36/// The access a task needs; this describes scope, not an authorization grant.
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
38pub enum AssetAccess {
39    /// Consume the asset without changing it.
40    Read,
41    /// Change the asset as part of the task contract.
42    Write,
43}
44
45/// One explicit asset needed to perform a task.
46#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
47#[serde(deny_unknown_fields)]
48pub struct AssetRequirement {
49    /// WorkspaceAsset in the workspace registry.
50    pub asset: AssetId,
51    /// Required access; one entry per asset.
52    pub access: AssetAccess,
53}
54
55pub(crate) fn validate(plan: &Plan) -> Result<(), ValidationError> {
56    let mut keys = BTreeSet::new();
57    for (id, r) in &plan.assets {
58        if id != &r.id
59            || r.key.0.trim().is_empty()
60            || r.key.0.trim() != r.key.0
61            || !keys.insert(&r.key)
62            || r.label.trim().is_empty()
63        {
64            return Err(invalid(
65                "asset",
66                id,
67                "asset id, unique key and label are required",
68            ));
69        }
70        match &r.kind {
71            AssetKind::Other(name) if name.trim().is_empty() => {
72                return Err(invalid("asset kind", id, "category cannot be empty"));
73            }
74            AssetKind::GitRepository { remotes } if remotes.iter().any(|r| r.trim().is_empty()) => {
75                return Err(invalid("asset remote", id, "address cannot be empty"));
76            }
77            AssetKind::GitRepository { remotes }
78                if remotes
79                    .iter()
80                    .any(|r| crate::credentials::check_remote(r).is_err()) =>
81            {
82                return Err(invalid(
83                    "asset remote",
84                    id,
85                    "shared plans cannot carry remote credentials",
86                ));
87            }
88            _ => {}
89        }
90    }
91    for work in plan.work_items.values() {
92        let mut seen = BTreeSet::new();
93        for requirement in &work.contract.assets {
94            if !plan.assets.contains_key(&requirement.asset) || !seen.insert(requirement.asset) {
95                return Err(invalid(
96                    "work asset",
97                    work.id,
98                    format!("missing or repeated asset {}", requirement.asset),
99                ));
100            }
101        }
102    }
103    Ok(())
104}
105
106#[cfg(test)]
107mod tests;