use std::path::{Path, PathBuf};
use anyhow::{Context, Result, bail};
use serde::Serialize;
use crate::db::{Db, Kind, Project};
use crate::{hub, import, repo, sync};
#[derive(Debug, Serialize)]
pub struct Adopted {
pub name: String,
pub path: String,
pub status: Status,
pub hub: Option<String>,
pub versions_added: u32,
pub tasks_added: u32,
pub diary_added: u32,
pub prose_files: u32,
pub tasks_dropped: u32,
pub versions_dropped: u32,
pub shipped: u32,
pub changes_read: u32,
pub warnings: Vec<String>,
}
impl Adopted {
pub fn hub_summary(&self) -> String {
let mut parts = Vec::new();
let mut part = |n: u32, one: &str, many: &str| {
if n > 0 {
parts.push(format!("{n} {}", if n == 1 { one } else { many }));
}
};
part(self.versions_added, "version", "versions");
part(self.tasks_added, "task", "tasks");
part(self.diary_added, "diary entry", "diary entries");
part(self.prose_files, "file of prose", "files of prose");
let mut struck = Vec::new();
let mut gone = |n: u32, one: &str, many: &str| {
if n > 0 {
struck.push(format!("{n} {}", if n == 1 { one } else { many }));
}
};
gone(self.versions_dropped, "version", "versions");
gone(self.tasks_dropped, "task", "tasks");
let struck = match struck.is_empty() {
true => String::new(),
false => format!("; {} struck", struck.join(", ")),
};
match parts.is_empty() {
true if struck.is_empty() => "hub: nothing new".to_string(),
true => format!("hub: nothing new{struck}"),
false => format!("hub: {} new{struck}", parts.join(", ")),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Status {
Recorded,
Known,
#[serde(rename = "no-hub")]
NoHub,
Skipped(String),
}
pub fn scan(root: &Path) -> Result<Vec<PathBuf>> {
let entries = std::fs::read_dir(root).with_context(|| format!("cannot read {}", root.display()))?;
let mut found = Vec::new();
for entry in entries {
let path = entry?.path();
if path.is_dir() && path.join(".git").exists() {
found.push(path);
}
}
found.sort();
Ok(found)
}
pub fn hub_for(hubs: Option<&Path>, name: &str) -> Option<PathBuf> {
let dir = hubs?.join(name);
if dir.is_dir() && hub::looks_like_a_hub(&dir) { Some(dir) } else { None }
}
pub fn adopt(db: &Db, root: &Path, hubs: Option<&Path>, check: bool) -> Result<Vec<Adopted>> {
let root = dunce::canonicalize(root).with_context(|| format!("{} is not a directory rigger can read", root.display()))?;
let hubs = match hubs {
Some(dir) => Some(dunce::canonicalize(dir).with_context(|| format!("{} is not a directory rigger can read", dir.display()))?),
None => None,
};
let hubs = hubs.as_deref();
if let Some(hubs) = hubs
&& !hubs.is_dir()
{
bail!("{} is not a directory", hubs.display());
}
let checkouts = scan(&root)?;
if checkouts.is_empty() {
bail!("no repositories under {}; a repository is a directory with a .git in it", root.display());
}
let mut out = Vec::new();
for checkout in checkouts {
let name = repo::detect_name(&checkout);
let path = checkout.to_string_lossy().into_owned();
let mut adopted = Adopted {
name: name.clone(),
path: path.clone(),
status: Status::Known,
hub: hub_for(hubs, &name).map(|h| h.display().to_string()),
versions_added: 0,
tasks_added: 0,
diary_added: 0,
prose_files: 0,
tasks_dropped: 0,
versions_dropped: 0,
shipped: 0,
changes_read: 0,
warnings: Vec::new(),
};
let project = match db.project_by_path(&path)? {
Some(project) => {
if project.name != name {
adopted.name = project.name.clone();
adopted.hub = hub_for(hubs, &project.name).map(|h| h.display().to_string());
}
Some(project)
}
None if db.project_by_name(&name)?.is_some() => {
let other = db.project_by_name(&name)?.map(|p| p.path).unwrap_or_default();
adopted.status = Status::Skipped(format!("a project named '{name}' is recorded at {other}"));
None
}
None if hubs.is_some() && adopted.hub.is_none() => {
adopted.status = Status::NoHub;
None
}
None => {
adopted.status = Status::Recorded;
if check {
None
} else {
let remote = repo::detect_remote(&checkout);
Some(db.add_project(&name, &path, remote.as_deref(), Kind::Repo)?)
}
}
};
if let Some(project) = project
&& !check
{
read_into(db, &project, &mut adopted)?;
}
out.push(adopted);
}
Ok(out)
}
fn read_into(db: &Db, project: &Project, adopted: &mut Adopted) -> Result<()> {
if let Some(dir) = adopted.hub.as_deref().map(Path::new) {
let hub = hub::read(dir)?;
db.set_hub_path(project.id, dir)?;
let report = import::import(db, project.id, &hub)?;
adopted.versions_added = report.versions_added;
adopted.tasks_added = report.tasks_added;
adopted.diary_added = report.diary_added;
adopted.prose_files = report.prose_files;
adopted.tasks_dropped = report.tasks_dropped;
adopted.versions_dropped = report.versions_dropped;
adopted.warnings.extend(report.warnings);
}
let report = sync::sync(db, project)?;
adopted.shipped = report.shipped.iter().filter(|s| s.newly).count() as u32;
adopted.changes_read = report.changes_recorded;
adopted.warnings.extend(report.warnings);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn only_checkouts_one_level_down_are_found() {
let dir = tempfile::tempdir().unwrap();
let a = dir.path().join("a");
std::fs::create_dir_all(a.join(".git")).unwrap();
let b = dir.path().join("b");
std::fs::write(b.with_extension("txt"), "").unwrap();
std::fs::create_dir_all(b.join("nested").join(".git")).unwrap();
let c = dir.path().join("c");
std::fs::create_dir_all(&c).unwrap();
std::fs::write(c.join(".git"), "gitdir: elsewhere").unwrap();
let found: Vec<String> = scan(dir.path()).unwrap().iter().map(|p| repo::detect_name(p)).collect();
assert_eq!(found, vec!["a", "c"]);
}
#[test]
fn a_hub_is_a_directory_with_hub_files_in_it() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("empty")).unwrap();
let real = dir.path().join("real");
std::fs::create_dir_all(&real).unwrap();
std::fs::write(real.join("План.md"), "# План\n").unwrap();
assert_eq!(hub_for(Some(dir.path()), "empty"), None);
assert_eq!(hub_for(Some(dir.path()), "missing"), None);
assert_eq!(hub_for(Some(dir.path()), "real"), Some(real));
assert_eq!(hub_for(None, "real"), None);
}
}