Skip to main content

intentional_core/
init.rs

1// ---
2// relationships:
3//   implements: intent-driven-polyglot-release
4// ---
5
6//! Manifest discovery for `intentional init`.
7
8use crate::config::{Config, PackageConfig, Projection, Settings, CONFIG_SCHEMA};
9use crate::error::{Error, Result};
10use crate::model::{Adapter, ProjectionMode};
11use std::collections::BTreeMap;
12use std::path::{Path, PathBuf};
13use walkdir::{DirEntry, WalkDir};
14
15/// Planned initialization output.
16#[derive(Debug, Clone)]
17pub struct InitResult {
18    /// Discovered configuration.
19    pub config: Config,
20    /// Target config path relative to the workspace.
21    pub path: PathBuf,
22    /// Serialized config contents.
23    pub contents: String,
24}
25
26impl InitResult {
27    /// Write the configuration and intent directory unless this is a dry run.
28    pub fn apply(&self, root: &Path, dry_run: bool) -> Result<()> {
29        if dry_run {
30            return Ok(());
31        }
32        let path = root.join(&self.path);
33        if path.exists() {
34            return Err(Error::Validation(format!(
35                "configuration already exists at {}",
36                self.path.display()
37            )));
38        }
39        let parent = path.parent().expect("config path has a parent");
40        std::fs::create_dir_all(parent).map_err(|error| Error::io(parent, error))?;
41        std::fs::write(&path, &self.contents).map_err(|error| Error::io(&path, error))?;
42        std::fs::create_dir_all(root.join(crate::intent::INTENTS_PATH))
43            .map_err(|error| Error::io(root.join(crate::intent::INTENTS_PATH), error))
44    }
45}
46
47/// Scan supported manifests and build a logical package inventory.
48pub fn discover_config(root: &Path) -> Result<InitResult> {
49    let mut packages: BTreeMap<String, PackageConfig> = BTreeMap::new();
50    for entry in WalkDir::new(root)
51        .into_iter()
52        .filter_entry(should_visit)
53        .filter_map(std::result::Result::ok)
54        .filter(|entry| entry.file_type().is_file())
55    {
56        let Some(adapter) = adapter_for(entry.path()) else {
57            continue;
58        };
59        if !is_project_manifest(entry.path(), adapter)? {
60            continue;
61        }
62        let directory = entry.path().parent().expect("manifest has parent");
63        let relative_directory = directory.strip_prefix(root).map_err(|error| {
64            Error::Validation(format!("manifest is outside workspace: {error}"))
65        })?;
66        let id = if relative_directory.as_os_str().is_empty() {
67            root.file_name()
68                .and_then(|name| name.to_str())
69                .unwrap_or("root")
70                .to_owned()
71        } else {
72            directory
73                .file_name()
74                .and_then(|name| name.to_str())
75                .ok_or_else(|| Error::Validation("non-Unicode package directory".to_owned()))?
76                .to_owned()
77        };
78        let package = packages.entry(id.clone()).or_insert_with(|| PackageConfig {
79            path: if relative_directory.as_os_str().is_empty() {
80                PathBuf::from(".")
81            } else {
82                relative_directory.to_owned()
83            },
84            projections: Vec::new(),
85            tag: "{id}@{version}".to_owned(),
86            depends_on: Vec::new(),
87        });
88        if package.path != relative_directory && package.path != Path::new(".") {
89            return Err(Error::Validation(format!(
90                "multiple package directories have id {id}; edit ids explicitly"
91            )));
92        }
93        let file = entry
94            .path()
95            .file_name()
96            .map(PathBuf::from)
97            .expect("manifest has filename");
98        package.projections.push(Projection {
99            adapter,
100            file,
101            mode: if adapter == Adapter::Go {
102                ProjectionMode::None
103            } else {
104                ProjectionMode::Committed
105            },
106            pointer: None,
107        });
108    }
109    if packages.is_empty() {
110        return Err(Error::Validation("no supported manifests found".to_owned()));
111    }
112    let config = Config {
113        schema: Some(CONFIG_SCHEMA.to_owned()),
114        settings: Settings::default(),
115        packages,
116    };
117    let contents = config.to_yaml()?;
118    Ok(InitResult {
119        config,
120        path: PathBuf::from(crate::config::CONFIG_PATH),
121        contents,
122    })
123}
124
125fn should_visit(entry: &DirEntry) -> bool {
126    !matches!(
127        entry.file_name().to_str(),
128        Some(".git" | ".intentional" | "node_modules" | "target")
129    )
130}
131
132fn adapter_for(path: &Path) -> Option<Adapter> {
133    let name = path.file_name()?.to_str()?;
134    match name {
135        "package.json" => Some(Adapter::Npm),
136        "Cargo.toml" => Some(Adapter::Cargo),
137        "pubspec.yaml" => Some(Adapter::Pub),
138        "pyproject.toml" => Some(Adapter::Python),
139        "go.mod" => Some(Adapter::Go),
140        name if name.ends_with(".csproj") => Some(Adapter::Msbuild),
141        _ => None,
142    }
143}
144
145fn is_project_manifest(path: &Path, adapter: Adapter) -> Result<bool> {
146    if adapter != Adapter::Cargo {
147        return Ok(true);
148    }
149    let text = std::fs::read_to_string(path).map_err(|error| Error::io(path, error))?;
150    let document = text
151        .parse::<toml_edit::DocumentMut>()
152        .map_err(|error| Error::Validation(format!("invalid Cargo.toml: {error}")))?;
153    Ok(document.get("package").is_some())
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159
160    #[test]
161    fn recognizes_supported_manifests() {
162        assert_eq!(adapter_for(Path::new("package.json")), Some(Adapter::Npm));
163        assert_eq!(
164            adapter_for(Path::new("module.csproj")),
165            Some(Adapter::Msbuild)
166        );
167        assert_eq!(adapter_for(Path::new("README.md")), None);
168    }
169}