1use 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#[derive(Debug, Clone)]
17pub struct InitResult {
18 pub config: Config,
20 pub path: PathBuf,
22 pub contents: String,
24}
25
26impl InitResult {
27 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
47pub 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}