use std::{path::Path, sync::Arc};
use crate::{
error::{LogoxideError, Result},
git::{Fetcher, GitCli, GitRef},
package::{Features, Package},
reader::{Skipped, TextReader},
storage::{FsStorage, MemoryStorage, Storage},
summary::{
Change, Diff, ENTRIES, HashRule, SUMMARY, Summary, VERSES, VerseHash, diff_summaries,
next_version, summarize, summarize_facts, version_change,
},
};
pub fn summary_at(
fetcher: &dyn Fetcher,
url: &str,
tag: &str,
reader: &dyn TextReader,
) -> Result<(Summary, Vec<VerseHash>)> {
summary_in(fetcher, url, tag, "", reader)
}
pub fn summary_in(
fetcher: &dyn Fetcher,
url: &str,
tag: &str,
prefix: &str,
reader: &dyn TextReader,
) -> Result<(Summary, Vec<VerseHash>)> {
let reference = GitRef::Tag(tag.to_string());
let paths: Vec<String> = [SUMMARY, VERSES, ENTRIES, crate::references::REFERENCES]
.iter()
.map(|p| format!("{prefix}{p}"))
.collect();
let paths: Vec<&str> = paths.iter().map(String::as_str).collect();
let fetched = MemoryStorage::new();
fetcher.fetch_files(url, &reference, &paths, &fetched)?;
if let Ok(published) = Summary::read(&within(&fetched, prefix)?)
&& published.0.verse_hash == HashRule::CURRENT.number()
{
return Ok(published);
}
let all = MemoryStorage::new();
fetcher.fetch(url, &reference, &all)?;
let package = Package::open(Arc::new(within(&all, prefix)?), Features::default())?;
summarize(&package, reader)
}
pub fn within(storage: &dyn Storage, prefix: &str) -> Result<MemoryStorage> {
let out = MemoryStorage::new();
for path in storage.walk("")? {
if let Some(inside) = path.strip_prefix(prefix) {
out.write(inside, &storage.read(&path)?)?;
}
}
Ok(out)
}
pub fn tag_for(prefix: &str, version: &semver::Version) -> String {
format!("{prefix}v{version}")
}
pub struct Plan {
pub summary: Summary,
pub verses: Vec<VerseHash>,
pub previous: Option<(semver::Version, Diff)>,
pub minimum: Option<semver::Version>,
pub skipped: Vec<Skipped>,
}
impl Plan {
pub fn change(&self) -> Change {
self.previous
.as_ref()
.map_or(Change::Patch, |(_, d)| d.change())
}
}
pub fn plan(repo: &Path, git: &GitCli, reader: &dyn TextReader) -> Result<Plan> {
let storage: Arc<dyn Storage> = Arc::new(FsStorage::new(repo).no_symlinks());
let package = Package::open(storage, Features::default())?;
let problems = reader.timing_problems(&package, None)?;
if !problems.is_empty() {
return Err(LogoxideError::Media(format!(
"the timings don't fit the text (lx media check --offline): {}",
problems.join("; ")
)));
}
let facts = reader.facts(&package, HashRule::CURRENT)?;
if !facts.untitled.is_empty() {
return Err(LogoxideError::Manifest(format!(
"no title for {} (give one in logoxide.toml's [titles])",
facts.untitled.join(", ")
)));
}
let skipped = facts.skipped.clone();
let (summary, verses) = summarize_facts(&package, facts)?;
let tags = git.local_versions(repo)?;
let Some((old_version, tag)) = tags.last().cloned() else {
return Ok(Plan {
summary,
verses,
previous: None,
minimum: None,
skipped,
});
};
let (root, prefix) = git.package_in(repo)?;
let url = root.display().to_string();
let (old_summary, old_verses) = summary_in(git, &url, &tag, &prefix, reader)?;
let d = diff_summaries((&old_summary, &old_verses), (&summary, &verses));
let minimum = Some(next_version(&old_version, d.change()));
Ok(Plan {
summary,
verses,
previous: Some((old_version, d)),
minimum,
skipped,
})
}
pub fn check(plan: &Plan) -> Result<()> {
let Some((old, d)) = &plan.previous else {
return Ok(());
};
let new = &plan.summary.version;
let needed = d.change();
let minimum = next_version(old, needed);
match version_change(old, new) {
None => Err(LogoxideError::Resolve(format!(
"version {new} isn't newer than the last release, {old}: release this as {minimum} or later"
))),
Some(change) if change < needed => Err(LogoxideError::Resolve(format!(
"{} since {old} is a {needed:?} change, so {new} is too small: release it as {minimum} or later",
describe(d)
))),
Some(_) => Ok(()),
}
}
pub fn describe(d: &Diff) -> String {
let count = |n: usize, what: &str, one: &str, many: &str| {
format!("{n} {} {what}", if n == 1 { one } else { many })
};
let mut parts: Vec<String> = [
(d.removed.len(), "removed"),
(d.moved.len(), "renumbered"),
(d.joined.len(), "joined into a bridge"),
(d.bridged.len(), "bridged differently"),
(d.split.len(), "split from a bridge"),
(d.changed.len(), "reworded"),
(d.added.len(), "added"),
]
.into_iter()
.filter(|(n, _)| *n > 0)
.map(|(n, what)| count(n, what, "verse", "verses"))
.collect();
parts.extend(
[
(d.entries_removed.len(), "removed"),
(d.entries_changed.len(), "reworded"),
(d.entries_added.len(), "added"),
]
.into_iter()
.filter(|(n, _)| *n > 0)
.map(|(n, what)| count(n, what, "entry", "entries")),
);
let what = if d.files_changed {
String::from("its files changed (it has no verses to compare)")
} else if parts.is_empty() {
String::from("no verse changed")
} else {
parts.join(", ")
};
match d.retitled.as_slice() {
[] => what,
retitled => {
let titles: Vec<String> = (retitled.iter())
.map(|(path, old, new)| format!("{path}: “{old}” is “{new}”"))
.collect();
format!("{what}; retitled {}", titles.join(", "))
}
}
}
pub fn release(repo: &Path, plan: &Plan, git: &GitCli) -> Result<(String, String)> {
check(plan)?;
plan.summary
.write(&plan.verses, &FsStorage::new(repo).no_symlinks())?;
let (_, prefix) = git.package_in(repo)?;
let tag = tag_for(&prefix, &plan.summary.version);
let commit = git.commit_and_tag(repo, &format!("Release {tag}"), &tag)?;
Ok((tag, commit))
}