Skip to main content

artifactize_core/config/
site.rs

1//! Where each Artifact is declared, and one Artifact's declarations read alone: what an
2//! Artifact's Detail needs without reading the whole workspace.
3
4use super::discovery::{FileTargets, Found, Reach, declaration, declare, discovery_ignore, walk};
5use super::*;
6
7/// Where an Artifact is declared: its kind and logical path, as a full read places it.
8#[derive(Debug, Clone, PartialEq, Eq)]
9pub struct Site {
10    pub kind: ArtifactKind,
11    pub path: PathBuf,
12}
13
14impl Site {
15    /// The folder the Artifact's declaration is in.
16    pub fn folder(&self) -> &Path {
17        folder_of(self.kind, &self.path)
18    }
19
20    /// The declaration file's logical path.
21    pub fn declaration_path(&self) -> PathBuf {
22        declaration_of(self.kind, &self.path)
23    }
24}
25
26pub(super) fn folder_of(kind: ArtifactKind, path: &Path) -> &Path {
27    match kind {
28        ArtifactKind::Folder | ArtifactKind::Graph => path,
29        ArtifactKind::File | ArtifactKind::Aspect => path.parent().unwrap_or(Path::new("")),
30    }
31}
32
33pub(super) fn declaration_of(kind: ArtifactKind, path: &Path) -> PathBuf {
34    match kind {
35        ArtifactKind::Graph => PathBuf::from(".artf"),
36        ArtifactKind::Folder => logical_join(path, std::ffi::OsStr::new(CONFIG_FILE)),
37        ArtifactKind::File | ArtifactKind::Aspect => {
38            let mut path = path.as_os_str().to_owned();
39            path.push(".artf");
40            PathBuf::from(path)
41        }
42    }
43}
44
45/// Every Artifact's site in the workspace at `repo`, from its declarations' names alone:
46/// nothing else in them is parsed or validated, so this is no check of the workspace.
47pub fn read_sites(repo: &Path) -> Result<BTreeMap<ArtifactName, Site>, ConfigError> {
48    let root =
49        crate::platform::canonicalize(repo).map_err(|error| ConfigError::new(repo, error))?;
50    let ignored = discovery_ignore(&root)?;
51    let mut sites = BTreeMap::new();
52    walk(
53        &root,
54        &ignored,
55        Reach::Workspace,
56        FileTargets::DeclarationsOnly,
57        &mut |found: Found<'_>| {
58            let name = declared_name(&found.source)
59                .map_err(|error| ConfigError::new(found.file, error))?;
60            let site = Site {
61                kind: found.kind,
62                path: found.path,
63            };
64            if sites.insert(name.clone(), site).is_some() {
65                return Err(ConfigError::declaration(
66                    found.file,
67                    &["name"],
68                    format!("Duplicate Artifact name: {name}."),
69                ));
70            }
71            Ok(name)
72        },
73    )?;
74    Ok(sites)
75}
76
77/// The `name` of a declaration, read without deserializing or validating the rest.
78fn declared_name(source: &str) -> Result<ArtifactName, String> {
79    let table = toml::de::DeTable::parse(source).map_err(|error| error.to_string())?;
80    match table.get_ref().get("name").map(|name| name.get_ref()) {
81        Some(toml::de::DeValue::String(name)) => name.parse(),
82        Some(_) => Err("name must be a string.".into()),
83        None => Err("missing field `name`".into()),
84    }
85}
86
87/// Why [`read_artifact_scope`] read no declarations.
88#[derive(Debug, Error)]
89pub enum ScopeReadError {
90    /// The site no longer declares the Artifact: where it is now needs [`read_sites`] again.
91    #[error("{name} is no longer declared at {}.", crate::platform::path_text(.path))]
92    Moved { name: ArtifactName, path: PathBuf },
93    #[error(transparent)]
94    Config(#[from] ConfigError),
95}
96
97/// The declarations of Artifact `name`, declared at `site`, and of the Artifacts it composes,
98/// each parsed and validated as a full read does; no other declaration is read. Checks across
99/// the workspace (unique names, references, the graph) stay with the full read, so the config
100/// holds only these Artifacts, their children and no evals.
101pub(crate) fn read_artifact_scope(
102    repo: &Path,
103    name: &ArtifactName,
104    site: &Site,
105) -> Result<RepoConfig, ScopeReadError> {
106    let mut config = read_declared(repo, name, site)?;
107    config.evals.clear();
108    Ok(config)
109}
110
111/// The declarations eval `id` reviews: those of the Artifacts in its scope, each declared at
112/// its site in `sites`, with the Artifacts they compose, and the eval resolved against them.
113/// No other declaration is read, and the checks across the workspace stay with the full read.
114/// The config holds no eval when the target no longer declares it, and is none when these
115/// declarations name an Artifact outside them, which only a full read places.
116pub(crate) fn read_eval_scope(
117    repo: &Path,
118    id: &crate::types::EvalId,
119    sites: &BTreeMap<ArtifactName, Site>,
120) -> Result<Option<RepoConfig>, ScopeReadError> {
121    let mut scoped: Option<RepoConfig> = None;
122    for (name, site) in sites {
123        let mut read = read_declared(repo, name, site)?;
124        read.evals.retain(|eval| &eval.id == id);
125        match &mut scoped {
126            None => scoped = Some(read),
127            Some(config) => {
128                for (name, artifact) in read.artifacts {
129                    config.artifacts.entry(name).or_insert(artifact);
130                }
131                if config.evals.is_empty() {
132                    config.evals = read.evals;
133                }
134            }
135        }
136    }
137    let Some(mut config) = scoped else {
138        return Ok(None);
139    };
140    let outside = sites
141        .keys()
142        .flat_map(|owner| named(&config.artifacts[owner]))
143        .any(|name| !config.artifacts.contains_key(name.as_str()));
144    if outside || crate::scope::resolve_evals(&mut config).is_err() {
145        return Ok(None);
146    }
147    Ok(Some(config))
148}
149
150/// The Artifacts an Artifact's fingerprint script and Human tools reference by name.
151fn named(artifact: &Artifact) -> impl Iterator<Item = &ReferenceName> {
152    let script = match &artifact.fingerprint {
153        Some(Fingerprint::Script { args, .. }) => args.as_slice(),
154        _ => &[],
155    };
156    let tools = artifact
157        .views
158        .human_tools
159        .values()
160        .flat_map(|tool| match tool {
161            HumanTool::Command(tool) => tool.args.as_slice(),
162            HumanTool::Builtin(tool) => tool.args.as_slice(),
163        });
164    script
165        .iter()
166        .chain(tools)
167        .filter_map(|argument| match argument {
168            crate::scope::Argument::Reference { name, .. } => Some(name),
169            crate::scope::Argument::Literal(_) => None,
170        })
171        // `{artifactPath}` stands for the tool's own Artifact.
172        .filter(|name| name.as_str() != "artifactPath")
173}
174
175/// [`read_artifact_scope`] with the evals these declarations declare.
176fn read_declared(
177    repo: &Path,
178    name: &ArtifactName,
179    site: &Site,
180) -> Result<RepoConfig, ScopeReadError> {
181    let root =
182        crate::platform::canonicalize(repo).map_err(|error| ConfigError::new(repo, error))?;
183    let moved = || ScopeReadError::Moved {
184        name: name.clone(),
185        path: site.declaration_path(),
186    };
187    let ignored = discovery_ignore(&root)?;
188    let folder = site.folder();
189    if !crate::platform::path_kind(&root.join(site.declaration_path()))
190        .is_ok_and(|kind| kind == crate::platform::FileKind::File)
191        || !folder.as_os_str().is_empty()
192            && ignored
193                .matched_path_or_any_parents(root.join(folder), true)
194                .is_ignore()
195    {
196        return Err(moved());
197    }
198    let reach = match site.kind {
199        ArtifactKind::Folder => Reach::Folder(&site.path),
200        ArtifactKind::File | ArtifactKind::Aspect | ArtifactKind::Graph => Reach::File(
201            folder,
202            site.path
203                .file_name()
204                .and_then(|target| target.to_str())
205                .unwrap_or_default(),
206        ),
207    };
208    let mut config = RepoConfig {
209        root: root.clone(),
210        artifacts: BTreeMap::new(),
211        evals: Vec::new(),
212        relations: Vec::new(),
213    };
214    let mut schemas = super::schemas::Schemas::default();
215    walk(
216        &root,
217        &ignored,
218        reach,
219        FileTargets::Required,
220        &mut |found: Found<'_>| {
221            let own = config.artifacts.is_empty();
222            if own && (found.kind != site.kind || found.path != site.path) {
223                return Err(moved());
224            }
225            let declaration = declaration(&found, &mut schemas)?;
226            if own && &declaration.name != name {
227                return Err(moved());
228            }
229            Ok(declare(&mut config, found, declaration)?)
230        },
231    )?;
232    if !config.artifacts.contains_key(name) {
233        return Err(moved());
234    }
235    Ok(config)
236}
237
238#[cfg(test)]
239mod tests;