use std::io::{BufRead, IsTerminal, Write};
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use crate::ui;
const MODULE_MANIFEST: &str = "portaki.module.json";
const MODULES_DIR: &str = "modules";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Member {
pub id: String,
pub root: PathBuf,
}
pub fn members(start: &Path) -> Vec<Member> {
start
.ancestors()
.map(nested_members)
.find(|found| !found.is_empty())
.unwrap_or_default()
}
fn nested_members(repo: &Path) -> Vec<Member> {
let Ok(entries) = std::fs::read_dir(repo.join(MODULES_DIR)) else {
return Vec::new();
};
let mut found: Vec<Member> = entries
.flatten()
.map(|entry| entry.path())
.filter(|root| root.join(MODULE_MANIFEST).is_file())
.map(|root| Member {
id: manifest_id(&root).unwrap_or_else(|| {
root.file_name()
.map(|name| name.to_string_lossy().to_string())
.unwrap_or_default()
}),
root,
})
.collect();
found.sort_by(|a, b| a.id.cmp(&b.id));
found
}
fn manifest_id(root: &Path) -> Option<String> {
let raw = std::fs::read_to_string(root.join(MODULE_MANIFEST)).ok()?;
let parsed: serde_json::Value = serde_json::from_str(&raw).ok()?;
parsed.get("id")?.as_str().map(str::to_string)
}
#[derive(Debug, PartialEq, Eq)]
enum Resolved {
Chosen(Vec<Member>),
Ambiguous,
}
fn decide(cwd: &Path, members: &[Member], module: Option<&str>, all: bool) -> Result<Resolved> {
if members.is_empty() {
let own = manifest_id(cwd);
if let (Some(wanted), Some(own)) = (module, own.as_deref()) {
if wanted != own {
anyhow::bail!("unknown module: {wanted} — this directory is {own}");
}
}
return Ok(Resolved::Chosen(vec![Member {
id: own.unwrap_or_default(),
root: cwd.to_path_buf(),
}]));
}
if all {
return Ok(Resolved::Chosen(members.to_vec()));
}
if let Some(wanted) = module {
return members
.iter()
.find(|member| member.id == wanted)
.map(|member| Resolved::Chosen(vec![member.clone()]))
.with_context(|| format!("unknown module: {wanted} — {}", ids(members)));
}
if let Some(inside) = members.iter().find(|member| cwd.starts_with(&member.root)) {
return Ok(Resolved::Chosen(vec![inside.clone()]));
}
if let [only] = members {
return Ok(Resolved::Chosen(vec![only.clone()]));
}
Ok(Resolved::Ambiguous)
}
pub fn resolve(module: Option<&str>, all: Option<bool>) -> Result<Vec<Member>> {
let cwd = std::env::current_dir().context("current_dir")?;
let members = members(&cwd);
match decide(&cwd, &members, module, all == Some(true))? {
Resolved::Chosen(chosen) => Ok(chosen),
Resolved::Ambiguous if std::io::stdin().is_terminal() => ask(&members).map(|m| vec![m]),
Resolved::Ambiguous => anyhow::bail!(
"this repository holds several modules — pass --module <id>{}: {}",
if all.is_some() { " or --all" } else { "" },
ids(&members)
),
}
}
fn ask(members: &[Member]) -> Result<Member> {
let numbers: Vec<String> = (1..=members.len()).map(|n| n.to_string()).collect();
let rows: Vec<(&str, &str)> = numbers
.iter()
.zip(members)
.map(|(number, member)| (number.as_str(), member.id.as_str()))
.collect();
ui::list("modules", &rows);
print!(" which one? ");
std::io::stdout().flush()?;
let mut answer = String::new();
std::io::stdin().lock().read_line(&mut answer)?;
let answer = answer.trim();
members
.iter()
.enumerate()
.find(|(index, member)| member.id == answer || (index + 1).to_string() == answer)
.map(|(_, member)| member.clone())
.with_context(|| format!("no module {answer:?} — {}", ids(members)))
}
fn ids(members: &[Member]) -> String {
members
.iter()
.map(|member| member.id.as_str())
.collect::<Vec<_>>()
.join(", ")
}
pub fn enter(member: &Member) -> Result<()> {
std::env::set_current_dir(&member.root)
.with_context(|| format!("enter {}", member.root.display()))
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn module(dir: &Path, id: &str) {
fs::create_dir_all(dir).unwrap();
fs::write(
dir.join(MODULE_MANIFEST),
format!(r#"{{"id":"{id}","version":"0.1.0"}}"#),
)
.unwrap();
}
fn monorepo() -> tempfile::TempDir {
let repo = tempfile::tempdir().unwrap();
module(&repo.path().join("modules/nuki"), "nuki");
module(&repo.path().join("modules/access-guide"), "access-guide");
fs::create_dir_all(repo.path().join("modules/not-a-module")).unwrap();
repo
}
fn chosen(resolved: Resolved) -> Vec<String> {
match resolved {
Resolved::Chosen(members) => members.into_iter().map(|m| m.id).collect(),
Resolved::Ambiguous => panic!("attendu un choix"),
}
}
#[test]
fn a_monorepo_is_found_from_its_root_and_from_inside_a_module() {
let repo = monorepo();
let ids: Vec<String> = members(repo.path()).into_iter().map(|m| m.id).collect();
assert_eq!(ids, vec!["access-guide", "nuki"]);
assert_eq!(members(&repo.path().join("modules/nuki/src")).len(), 2);
}
#[test]
fn a_single_module_repository_is_not_a_monorepo() {
let repo = tempfile::tempdir().unwrap();
module(repo.path(), "weather");
assert!(members(repo.path()).is_empty());
assert_eq!(
chosen(decide(repo.path(), &[], None, false).unwrap()),
vec!["weather"]
);
assert!(decide(repo.path(), &[], Some("nuki"), false).is_err());
}
#[test]
fn the_root_of_a_monorepo_asks_unless_told() {
let repo = monorepo();
let found = members(repo.path());
assert_eq!(
decide(repo.path(), &found, None, false).unwrap(),
Resolved::Ambiguous
);
assert_eq!(
chosen(decide(repo.path(), &found, Some("nuki"), false).unwrap()),
vec!["nuki"]
);
assert_eq!(
chosen(decide(repo.path(), &found, None, true).unwrap()),
vec!["access-guide", "nuki"]
);
let unknown = decide(repo.path(), &found, Some("wifi"), false)
.unwrap_err()
.to_string();
assert!(unknown.contains("access-guide, nuki"), "{unknown}");
}
#[test]
fn inside_a_module_that_module_is_the_answer() {
let repo = monorepo();
let found = members(repo.path());
let inside = repo.path().join("modules/nuki/src");
assert_eq!(
chosen(decide(&inside, &found, None, false).unwrap()),
vec!["nuki"]
);
}
}