use std::path::{Path, PathBuf};
use anyhow::{Context, Result, bail};
use crate::{go, node, php, python, ruby, rust, store, terraform, zig};
const MANIFESTS: &[(&str, &str)] = &[
("pyproject.toml", "python"),
("package.json", "node"),
("Gemfile", "ruby"),
("Cargo.toml", "rust"),
("go.mod", "go"),
("build.zig", "zig"),
("composer.json", "php"),
];
const SKIP_DIRS: &[&str] = &[
"node_modules",
"target",
"vendor",
"dist",
"build",
"__pycache__",
".venv",
"venv",
];
fn workspace_members(path: &Path) -> Result<Option<Vec<String>>> {
if !path.is_file() {
return Ok(None);
}
let text = std::fs::read_to_string(path)
.with_context(|| format!("failed to read {}", path.display()))?;
let doc: toml_edit::DocumentMut = text
.parse()
.with_context(|| format!("failed to parse {}", path.display()))?;
Ok(doc
.get("workspace")
.and_then(|w| w.get("members"))
.and_then(|m| m.as_array())
.map(|a| {
a.iter()
.filter_map(|i| i.as_str().map(str::to_string))
.collect()
}))
}
fn is_member(dir: &Path) -> bool {
member_languages(dir).is_ok_and(|langs| !langs.is_empty())
}
fn member_languages(dir: &Path) -> Result<Vec<&'static str>> {
let mut languages = Vec::new();
for (manifest, language) in MANIFESTS {
if dir.join(manifest).is_file() {
languages.push(*language);
}
}
let has_tf = std::fs::read_dir(dir)
.ok()
.into_iter()
.flatten()
.filter_map(|e| e.ok())
.any(|e| e.path().extension().is_some_and(|ext| ext == "tf"));
if has_tf {
languages.push(terraform::LANGUAGE);
}
Ok(languages)
}
fn discover(root: &Path, members: &mut Vec<PathBuf>) -> Result<()> {
if is_member(root) {
members.push(root.to_path_buf());
}
let entries = match std::fs::read_dir(root) {
Ok(entries) => entries,
Err(_) => return Ok(()),
};
let mut subdirs: Vec<PathBuf> = entries
.filter_map(|e| e.ok())
.filter(|e| e.file_type().is_ok_and(|t| t.is_dir()))
.map(|e| e.path())
.filter(|p| {
p.file_name()
.and_then(|n| n.to_str())
.is_some_and(|name| !name.starts_with('.') && !SKIP_DIRS.contains(&name))
})
.collect();
subdirs.sort();
for subdir in subdirs {
discover(&subdir, members)?;
}
Ok(())
}
fn resolve_members(cwd: &Path) -> Result<(PathBuf, Vec<PathBuf>)> {
for dir in cwd.ancestors() {
if let Some(patterns) = workspace_members(&dir.join(crate::config::PIN_FILE))? {
let mut members = Vec::new();
for pattern in &patterns {
let full = dir.join(pattern);
let full = full.to_string_lossy();
let mut matched = false;
for entry in glob::glob(&full)
.with_context(|| format!("invalid workspace member pattern '{pattern}'"))?
{
let path = entry?;
if path.is_dir() && is_member(&path) {
members.push(path);
matched = true;
}
}
if !matched {
eprintln!("warning: workspace member pattern '{pattern}' matched nothing");
}
}
members.sort();
members.dedup();
return Ok((dir.to_path_buf(), members));
}
}
let mut members = Vec::new();
discover(cwd, &mut members)?;
Ok((cwd.to_path_buf(), members))
}
fn ensure_toolchain(language: &str, dir: &Path) -> Result<bool> {
match language {
"rust" => {
if rust::resolve_active(dir)?.is_some() {
return Ok(true);
}
match store::resolve_pin(language, dir)? {
Some(pin) => rust::install(Some(pin.raw)).map(|_| true),
None => Ok(false),
}
}
"terraform" => {
if terraform::resolve_active(dir)?.is_some() {
return Ok(true);
}
match crate::config::resolve_pin(language, dir)? {
Some(pin) => terraform::install(Some(pin.raw)).map(|_| true),
None => Ok(false),
}
}
_ => {
if store::resolve_active(language, dir)?.is_some() {
return Ok(true);
}
match store::resolve_pin(language, dir)? {
Some(pin) => {
let raw = Some(pin.raw);
match language {
"python" => python::install(raw)?,
"node" => node::install(raw)?,
"ruby" => ruby::install(raw)?,
"go" => go::install(raw)?,
"zig" => zig::install(raw)?,
"php" => php::install(raw)?,
other => bail!("no installer for {other}"),
}
Ok(true)
}
None => Ok(false),
}
}
}
}
fn sync_language(language: &str, dir: &Path) -> Result<()> {
match language {
"python" => python::project::sync_in(dir),
"node" => node::project::sync_in(dir),
"ruby" => ruby::project::sync_in(dir),
"rust" => rust::project::sync_in(dir),
"go" => go::project::sync_in(dir),
"zig" => zig::project::sync_in(dir),
"php" => php::project::sync_in(dir),
"terraform" => Ok(()),
other => bail!("no sync for {other}"),
}
}
pub fn sync() -> Result<()> {
let cwd = std::env::current_dir()?;
let (root, members) = resolve_members(&cwd)?;
if members.is_empty() {
println!(
"no workspace members found under {} (nothing with a project manifest)",
root.display()
);
return Ok(());
}
let mut failures: Vec<String> = Vec::new();
for member in &members {
let display = member
.strip_prefix(&root)
.ok()
.filter(|p| !p.as_os_str().is_empty())
.map(|p| p.display().to_string())
.unwrap_or_else(|| ".".to_string());
for language in member_languages(member)? {
println!("{display}: {language}");
match ensure_toolchain(language, member) {
Ok(true) => {
if let Err(err) = sync_language(language, member) {
eprintln!(" {err:#}");
failures.push(format!("{display} ({language})"));
}
}
Ok(false) => {
println!(" no {language} version pinned; skipped");
}
Err(err) => {
eprintln!(" {err:#}");
failures.push(format!("{display} ({language})"));
}
}
}
}
if !failures.is_empty() {
bail!("sync failed for: {}", failures.join(", "));
}
println!("workspace in sync ({} members)", members.len());
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn touch(path: &Path) {
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, "").unwrap();
}
fn names(members: &[PathBuf], root: &Path) -> Vec<String> {
members
.iter()
.map(|m| {
m.strip_prefix(root)
.unwrap()
.display()
.to_string()
.replace('\\', "/")
})
.collect()
}
#[test]
fn discovery_finds_members_and_skips_vendor_dirs() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
touch(&root.join("api/pyproject.toml"));
touch(&root.join("web/package.json"));
touch(&root.join("web/node_modules/dep/package.json"));
touch(&root.join("infra/main.tf"));
touch(&root.join(".hidden/Cargo.toml"));
touch(&root.join("tools/cli/go.mod"));
let mut members = Vec::new();
discover(root, &mut members).unwrap();
assert_eq!(
names(&members, root),
vec!["api", "infra", "tools/cli", "web"]
);
}
#[test]
fn declared_members_win_and_glob() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::write(
root.join("linguo.toml"),
"[workspace]\nmembers = [\"services/*\", \"web\"]\n",
)
.unwrap();
touch(&root.join("services/a/pyproject.toml"));
touch(&root.join("services/b/package.json"));
touch(&root.join("services/fixtures/README.md")); touch(&root.join("web/package.json"));
touch(&root.join("unlisted/Cargo.toml"));
let nested = root.join("services/a");
let (found_root, members) = resolve_members(&nested).unwrap();
assert_eq!(found_root, root);
assert_eq!(
names(&members, root),
vec!["services/a", "services/b", "web"]
);
}
#[test]
fn member_languages_detects_terraform() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
touch(&root.join("main.tf"));
touch(&root.join("Cargo.toml"));
let langs = member_languages(root).unwrap();
assert!(langs.contains(&"terraform"));
assert!(langs.contains(&"rust"));
}
}