use std::{
collections::HashMap,
path::{Path, PathBuf},
};
use crate::{
catalog::Catalog,
error::{LogoxideError, Result},
manifest::{MANIFEST, Manifest},
};
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Release {
pub tag: String,
pub commit: String,
pub manifest: String,
}
pub fn git_in(repo: &Path, args: &[&str]) -> Result<String> {
let output = std::process::Command::new("git")
.args(["-c", "core.fsmonitor=false"])
.arg("-C")
.arg(repo)
.args(args)
.output()
.map_err(|e| LogoxideError::git(&repo.display().to_string(), format!("git: {e}")))?;
if !output.status.success() {
return Err(LogoxideError::git(
&repo.display().to_string(),
format!(
"git {}: {}",
args.join(" "),
String::from_utf8_lossy(&output.stderr).trim()
),
));
}
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
}
pub fn package_repositories(folder: &Path) -> Result<Vec<(String, PathBuf)>> {
let mut found = vec![];
let mut pending = vec![folder.to_path_buf()];
while let Some(dir) = pending.pop() {
if dir.join(".git").exists() && dir.join(MANIFEST).is_file() {
let path = dir.strip_prefix(folder).unwrap_or(&dir);
let path: Vec<String> = (path.components())
.map(|c| c.as_os_str().to_string_lossy().into_owned())
.collect();
found.push((path.join("/"), dir));
continue;
}
let entries = std::fs::read_dir(&dir)
.map_err(|e| LogoxideError::io(&dir.display().to_string(), e))?;
for entry in entries.flatten() {
let hidden = entry.file_name().to_string_lossy().starts_with('.');
if !hidden && entry.file_type().is_ok_and(|t| t.is_dir()) {
pending.push(entry.path());
}
}
}
found.sort();
Ok(found)
}
pub fn newest_release(repo: &Path) -> Result<Option<Release>> {
let refs = git_in(
repo,
&[
"for-each-ref",
"--format=%(refname:strip=2) %(objectname) %(*objectname)",
"refs/tags",
],
)?;
let newest = (refs.lines())
.filter_map(|line| {
let mut parts = line.split_whitespace();
let tag = parts.next()?;
let object = parts.next()?;
let commit = parts.next().unwrap_or(object);
let version = crate::resolve::tag_version(tag).filter(|v| v.pre.is_empty())?;
Some((version, tag.to_string(), commit.to_string()))
})
.max_by(|a, b| a.0.cmp(&b.0));
let Some((_, tag, commit)) = newest else {
return Ok(None);
};
let manifest = git_in(repo, &["show", &format!("{commit}:{MANIFEST}")])?;
Ok(Some(Release {
tag,
commit,
manifest,
}))
}
pub fn folder_catalog(name: &str, folder: &Path) -> Result<(Catalog, Vec<String>)> {
let (mut packages, mut warnings) = (vec![], vec![]);
for (path, dir) in package_repositories(folder)? {
match newest_release(&dir) {
Ok(Some(release)) => match Manifest::parse(&release.manifest) {
Ok(parsed) => packages.push((path, parsed)),
Err(e) => warnings.push(format!("{path} {}: {e}", release.tag)),
},
Ok(None) => {}
Err(e) => warnings.push(format!("{path}: {e}")),
}
}
let url = format!("file://{}", folder.display());
let mut catalog = Catalog::from_packages(name, &url, packages);
let works = folder.join(crate::works::WORKS_FILE);
if let Some(text) = read_works_file(&works)? {
match crate::works::read_table(&text) {
Ok(table) => {
let at = works.display().to_string();
let (declared, passed_over) = crate::works::parse_declared(name, Some(&at), table);
catalog.works = declared;
warnings.extend(passed_over);
}
Err(e) => warnings.push(format!("{}: {e}", works.display())),
}
}
Ok((catalog, warnings))
}
fn read_works_file(path: &Path) -> Result<Option<String>> {
match std::fs::read_to_string(path) {
Ok(text) => Ok(Some(text)),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(LogoxideError::io(&path.display().to_string(), e)),
}
}
#[derive(Clone, Debug)]
pub struct BuiltCatalog {
pub text: String,
pub count: usize,
pub read: usize,
pub kept: usize,
pub skipped: Vec<String>,
pub works: usize,
pub warnings: Vec<String>,
}
pub fn build_catalog(
folder: &Path,
previous: Option<&str>,
url: Option<&str>,
name: Option<&str>,
) -> Result<BuiltCatalog> {
let name = name.map(String::from).unwrap_or_else(|| {
(folder.file_name())
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default()
});
let mut before: HashMap<String, toml_edit::Table> = HashMap::new();
let mut works: Option<toml_edit::Item> = None;
if let Some(text) = previous {
let doc: toml_edit::DocumentMut = text
.parse()
.map_err(|e| LogoxideError::Catalog(format!("the previous catalog: {e}")))?;
works = doc.get("works").cloned();
if let Some(packages) = doc.get("package").and_then(|p| p.as_array_of_tables()) {
for table in packages.iter() {
if let Some(path) = table.get("path").and_then(|p| p.as_str()) {
before.insert(path.to_string(), table.clone());
}
}
}
}
let (mut tables, mut read, mut kept, mut skipped) = (vec![], 0, 0, vec![]);
for (path, repo) in package_repositories(folder)? {
let folder_name = path.rsplit('/').next().unwrap_or(&path);
let git = match url {
Some(template) => template
.replace("{path}", &path)
.replace("{name}", folder_name),
None => format!("file://{}", repo.display()),
};
let release = match newest_release(&repo) {
Ok(Some(release)) => release,
Ok(None) => {
skipped.push(format!("{path} (no release yet: lx release)"));
continue;
}
Err(e) => {
skipped.push(format!("{path} ({e})"));
continue;
}
};
let unchanged = before.remove(&path).filter(|t| {
t.get("commit").and_then(|c| c.as_str()) == Some(release.commit.as_str())
&& t.get("git").and_then(|g| g.as_str()) == Some(git.as_str())
});
if let Some(table) = unchanged {
tables.push(table);
kept += 1;
continue;
}
let info = match Manifest::parse(&release.manifest) {
Ok(manifest) => match manifest.package {
Some(info) => info,
None => {
skipped.push(format!("{path} (a vault, not a package)"));
continue;
}
},
Err(e) => {
skipped.push(format!("{path} {} ({e})", release.tag));
continue;
}
};
let mut table = Catalog::entry_table(&info, &git);
table.insert("path", toml_edit::value(&path));
table.insert("commit", toml_edit::value(&release.commit));
tables.push(table);
read += 1;
}
let count = tables.len();
let works_file = folder.join(crate::works::WORKS_FILE);
if let Some(text) = read_works_file(&works_file)? {
let doc: toml_edit::DocumentMut = (text.parse())
.map_err(|e| LogoxideError::Catalog(format!("{}: {e}", works_file.display())))?;
let mut table = doc.as_table().clone();
table.set_implicit(true);
works = Some(toml_edit::Item::Table(table));
}
let mut doc: toml_edit::DocumentMut = (Catalog::toml_document(Some(&name), tables).parse())
.map_err(|e: toml_edit::TomlError| LogoxideError::Catalog(e.to_string()))?;
if let Some(works) = works {
doc.insert("works", works);
}
let text = format!(
"# Written by lx catalog build: the newest release of each package in {}\n{doc}",
folder.display(),
);
let parsed = Catalog::parse_toml(&text)?;
Ok(BuiltCatalog {
text,
count,
read,
kept,
skipped,
works: parsed.works.len(),
warnings: parsed.warnings,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn git(dir: &Path, args: &[&str]) {
let out = std::process::Command::new("git")
.args([
"-c",
"user.name=t",
"-c",
"user.email=t@t",
"-c",
"commit.gpgsign=false",
"-c",
"tag.gpgsign=false",
])
.args(args)
.current_dir(dir)
.output()
.unwrap();
assert!(
out.status.success(),
"git {args:?}: {}",
String::from_utf8_lossy(&out.stderr)
);
}
fn package(repo: &Path, id: &str, version: &str) {
std::fs::create_dir_all(repo).unwrap();
std::fs::write(
repo.join(MANIFEST),
format!(
"[package]\nid = \"{id}\"\nname = \"{id}\"\nabbreviation = \"{id}\"\nversion = \"{version}\"\nlanguage = \"en\"\n\n[formats]\nusx = {{ path = \"usx/\" }}\n"
),
)
.unwrap();
}
fn commit_and_tag(repo: &Path, tag: Option<&str>) {
git(repo, &["add", "-A"]);
git(repo, &["commit", "-q", "--allow-empty", "-m", "release"]);
if let Some(tag) = tag {
git(repo, &["tag", "-a", tag, "-m", tag]);
}
}
#[test]
fn folders_of_repositories_are_catalogs() {
let root = std::env::temp_dir().join(format!("logoxide-registry-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
let romans = root.join("books/romans");
package(&romans, "romans", "1.0.0");
git(&romans, &["init", "-q", "-b", "main"]);
commit_and_tag(&romans, Some("v1.0.0"));
package(&romans, "romans", "1.1.0");
commit_and_tag(&romans, Some("v1.1.0"));
commit_and_tag(&romans, None);
git(&romans, &["tag", "v2.0.0-beta.1"]);
let gill = root.join("gill");
package(&gill, "gill", "0.1.0");
git(&gill, &["init", "-q", "-b", "main"]);
commit_and_tag(&gill, Some("v0.1.0"));
let draft = root.join("draft");
package(&draft, "draft", "0.1.0");
git(&draft, &["init", "-q", "-b", "main"]);
commit_and_tag(&draft, None);
package(&root.join(".hidden"), "hidden", "1.0.0");
let found: Vec<String> = (package_repositories(&root).unwrap().into_iter())
.map(|(path, _)| path)
.collect();
assert_eq!(found, ["books/romans", "draft", "gill"]);
let newest = newest_release(&romans).unwrap().unwrap();
assert_eq!(newest.tag, "v1.1.0");
assert!(newest.manifest.contains("version = \"1.1.0\""));
assert_eq!(
newest.commit,
git_in(&romans, &["rev-parse", "v1.1.0^{commit}"])
.unwrap()
.trim()
);
assert!(newest_release(&draft).unwrap().is_none());
let (catalog, warnings) = folder_catalog("mine", &root).unwrap();
assert!(warnings.is_empty(), "{warnings:?}");
let ids: Vec<&str> = catalog.entries.iter().map(|e| e.id.as_str()).collect();
assert_eq!(ids, ["romans", "gill"]);
let built = build_catalog(&root, None, Some("https://example.org/{name}"), None).unwrap();
assert_eq!((built.count, built.read, built.kept), (2, 2, 0));
assert_eq!(built.skipped, ["draft (no release yet: lx release)"]);
assert!(built.text.contains("git = \"https://example.org/romans\""));
assert!(built.text.contains("path = \"books/romans\""));
let parsed = Catalog::parse("catalog.toml", &built.text).unwrap();
assert_eq!(parsed.entries.len(), 2);
package(&gill, "gill", "0.2.0");
commit_and_tag(&gill, Some("v0.2.0"));
let again = build_catalog(
&root,
Some(&built.text),
Some("https://example.org/{name}"),
Some("mine"),
)
.unwrap();
assert_eq!((again.count, again.read, again.kept), (2, 1, 1));
assert!(again.text.contains("version = \"0.2.0\""));
assert!(again.text.contains("name = \"mine\""));
assert_eq!(again.works, 0);
std::fs::write(
root.join("works.toml"),
"# Gill's works\n[gill]\nname = \"John Gill's Exposition\"\n\n[kjv]\nname = \"Mine\"\n",
)
.unwrap();
let (catalog, warnings) = folder_catalog("mine", &root).unwrap();
assert_eq!(catalog.work("gill").unwrap().name, "John Gill's Exposition");
assert_eq!(catalog.works[0].catalog, "mine");
assert_eq!(warnings.len(), 1, "{warnings:?}");
let built = build_catalog(&root, Some(&again.text), None, Some("mine")).unwrap();
assert_eq!(built.works, 1);
assert_eq!(built.warnings.len(), 1, "{:?}", built.warnings);
assert!(built.text.contains("[works.gill]"), "{}", built.text);
let parsed = Catalog::parse("catalog.toml", &built.text).unwrap();
assert_eq!(parsed.work("gill").unwrap().name, "John Gill's Exposition");
std::fs::remove_file(root.join("works.toml")).unwrap();
let kept = build_catalog(&root, Some(&built.text), None, Some("mine")).unwrap();
assert_eq!(kept.works, 1);
assert!(kept.text.contains("[works.gill]"));
let _ = std::fs::remove_dir_all(&root);
}
}