use std::io::{BufRead, IsTerminal, Write};
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use crate::ui;
use crate::manifest::source::{is_module, module_id as manifest_id};
const MODULES_DIR: &str = "modules";
#[derive(Debug, Clone, Default, clap::Args)]
pub struct ModuleArgs {
#[arg(long, value_name = "ID", conflicts_with = "all")]
pub module: Option<String>,
#[arg(long)]
pub all: bool,
}
impl ModuleArgs {
pub fn resolve(&self) -> Result<Vec<Member>> {
resolve(self.module.as_deref(), Some(self.all))
}
pub fn one(&self, command: &str) -> Result<Member> {
if self.all {
return Err(crate::exit::usage(crate::tr!(
"portaki {command} acts on one module at a time — pass --module <id> instead of --all",
"portaki {command} agit sur un module à la fois — passez --module <id> au lieu de --all"
)));
}
resolve(self.module.as_deref(), None)?
.into_iter()
.next()
.context("no module here")
}
pub fn for_each(&self, mut run: impl FnMut(&Member) -> Result<()>) -> Result<()> {
let start = std::env::current_dir().context("current_dir")?;
let chosen = self.resolve()?;
let many = chosen.len() > 1;
let mut ran = Ok(());
for member in &chosen {
if many {
ui::rule(&member.id);
}
enter(member)?;
ran = run(member).with_context(|| format!("module {}", member.id));
if ran.is_err() {
break;
}
}
std::env::set_current_dir(&start).context("return to the starting directory")?;
ran
}
}
#[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| is_module(root))
.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
}
#[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 {
return Err(crate::exit::usage(crate::tr!(
"unknown module: {wanted} — this directory is {own}",
"module inconnu : {wanted} — ce dossier est {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()]))
.ok_or_else(|| {
crate::exit::usage(crate::tr!(
"unknown module: {wanted} — {}",
"module inconnu : {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() && !ui::json() => {
ask(&members).map(|m| vec![m])
}
Resolved::Ambiguous => Err(crate::exit::usage(crate::tr!(
"this repository holds several modules — pass --module <id>{}: {}",
"ce dépôt porte plusieurs modules — passez --module <id>{} : {}",
if all.is_some() {
crate::tr!(" or --all", " ou --all")
} else {
String::new()
},
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!(" {} ", crate::tr!("which one?", "lequel ?"));
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 crate::manifest::source::MODULE_MANIFEST;
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();
assert!(
unknown.to_string().contains("access-guide, nuki"),
"{unknown}"
);
assert_eq!(crate::exit::code(&unknown), 2);
}
#[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"]
);
}
}