use std::collections::{BTreeMap, BTreeSet};
use std::fmt::{self, Write as _};
use sealmap_dense::{Dense, SliceOptions};
use sealmap_model::{Codebase, ContentHash, Fingerprint, SourcePath, SourceSet, SymbolId};
use serde::Serialize;
use crate::seal::{GENERATOR, Lock, Resolution, Topics, citations, resolve, stale, topic_id};
mod selection;
pub use selection::changed_topics_by_files;
pub const PACK_FORMAT_VERSION: u32 = 2;
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct PackOptions {
pub depth: usize,
pub source_window: usize,
pub budget: Option<usize>,
}
impl Default for PackOptions {
fn default() -> Self {
Self { depth: 1, source_window: 40, budget: None }
}
}
impl PackOptions {
pub fn depth(mut self, depth: usize) -> Self {
self.depth = depth;
self
}
pub fn source_window(mut self, lines: usize) -> Self {
self.source_window = lines;
self
}
pub fn budget(mut self, bytes: usize) -> Self {
self.budget = Some(bytes);
self
}
}
#[derive(Debug, Clone, Copy)]
#[non_exhaustive]
pub struct PackInput<'a> {
pub codebase: &'a Codebase,
pub sources: &'a SourceSet,
pub topics: &'a Topics,
pub revision: &'a str,
pub diff: Option<&'a str>,
pub provenance: Option<&'a str>,
}
impl<'a> PackInput<'a> {
pub fn new(codebase: &'a Codebase, sources: &'a SourceSet, topics: &'a Topics, revision: &'a str) -> Self {
Self { codebase, sources, topics, revision, diff: None, provenance: None }
}
pub fn diff(mut self, base: &'a str) -> Self {
self.diff = Some(base);
self
}
pub fn provenance(mut self, provenance: &'a str) -> Self {
self.provenance = Some(provenance);
self
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
pub struct TopicSize {
pub id: String,
pub bytes: usize,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Pack {
pub text: String,
pub header: usize,
pub topics: Vec<TopicSize>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(tag = "error", rename_all = "snake_case")]
#[non_exhaustive]
pub enum PackError {
NoTopics,
UnknownTopics {
ids: Vec<String>,
},
DuplicateTopic {
id: String,
files: Vec<SourcePath>,
},
OverBudget {
bytes: usize,
budget: usize,
header: usize,
topics: Vec<TopicSize>,
},
TopicOverBudget {
id: String,
bytes: usize,
budget: usize,
},
}
impl fmt::Display for PackError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::NoTopics => write!(f, "no topic to pack"),
Self::UnknownTopics { ids } => write!(f, "no topic has the id(s) {}", ids.join(", ")),
Self::DuplicateTopic { id, files } => {
let files: Vec<&str> = files.iter().map(SourcePath::as_str).collect();
write!(f, "topic id {id} is carried by {} files: {}", files.len(), files.join(", "))
}
Self::OverBudget { bytes, budget, header, topics } => {
write!(
f,
"pack is {bytes} bytes, {} over the {budget}-byte budget; refusing rather than truncating (header {header}",
bytes - budget
)?;
for t in topics {
write!(f, ", {} {}", t.id, t.bytes)?;
}
write!(f, ")")
}
Self::TopicOverBudget { id, bytes, budget } => {
write!(f, "topic {id} alone makes a {bytes}-byte pack, over the {budget}-byte budget")
}
}
}
}
impl std::error::Error for PackError {}
pub fn pack(input: &PackInput<'_>, ids: &[String], options: &PackOptions) -> Result<Pack, PackError> {
let builder = Builder::new(input, ids, options)?;
let sections = builder.sections();
let pack = builder.assemble(§ions, None);
match options.budget {
Some(budget) if pack.text.len() > budget => {
Err(PackError::OverBudget { bytes: pack.text.len(), budget, header: pack.header, topics: pack.topics })
}
_ => Ok(pack),
}
}
pub fn shard(input: &PackInput<'_>, ids: &[String], options: &PackOptions) -> Result<Vec<Pack>, PackError> {
let builder = Builder::new(input, ids, options)?;
let sections = builder.sections();
let Some(budget) = options.budget else { return Ok(vec![builder.assemble(§ions, None)]) };
let mut plan: Vec<Vec<usize>> = Vec::new();
let mut current: Vec<usize> = Vec::new();
for (i, section) in sections.iter().enumerate() {
let size = |members: &[usize], shard: usize| {
let ids: Vec<&str> = members.iter().map(|&m| sections[m].id.as_str()).collect();
builder.header(&ids, Some(shard)).len() + members.iter().map(|&m| sections[m].text.len()).sum::<usize>()
};
current.push(i);
if size(¤t, plan.len() + 1) <= budget {
continue;
}
current.pop();
if !current.is_empty() {
plan.push(std::mem::take(&mut current));
}
let alone = size(&[i], plan.len() + 1);
if alone > budget {
return Err(PackError::TopicOverBudget { id: section.id.clone(), bytes: alone, budget });
}
current.push(i);
}
if !current.is_empty() {
plan.push(current);
}
Ok(plan
.iter()
.enumerate()
.map(|(n, members)| {
let chosen: Vec<Section> = members.iter().map(|&m| sections[m].clone()).collect();
builder.assemble(&chosen, Some(n + 1))
})
.collect())
}
pub fn changed_topics(lock: Option<&Lock>, topics: &Topics, before: &Codebase, after: &Codebase) -> Vec<String> {
let mut out: BTreeSet<String> =
lock.map(|l| stale(l, Some(before), after).into_iter().map(|s| s.topic).collect()).unwrap_or_default();
for text in topics.values() {
let Some(id) = topic_id(text) else { continue };
let changed = citations(text)
.iter()
.filter_map(|c| c.id.as_ref().ok())
.any(|sym| SymbolState::of(before, sym) != SymbolState::of(after, sym));
if changed {
out.insert(id);
}
}
out.into_iter().collect()
}
#[derive(Debug, PartialEq, Eq)]
enum SymbolState {
Found(Fingerprint, Fingerprint),
Unparsable,
Absent,
}
impl SymbolState {
fn of(codebase: &Codebase, id: &SymbolId) -> Self {
match resolve(codebase, id, None) {
Resolution::Found { sig, body, .. } => Self::Found(sig, body),
Resolution::Unparsable { .. } => Self::Unparsable,
Resolution::Absent { .. } => Self::Absent,
}
}
}
#[derive(Debug, Clone)]
struct Section {
id: String,
text: String,
}
struct Builder<'a, 'i> {
input: &'i PackInput<'a>,
options: &'i PackOptions,
selected: BTreeMap<String, (&'a SourcePath, &'a str)>,
source_hash: ContentHash,
}
impl<'a, 'i> Builder<'a, 'i> {
fn new(input: &'i PackInput<'a>, ids: &[String], options: &'i PackOptions) -> Result<Self, PackError> {
if ids.is_empty() {
return Err(PackError::NoTopics);
}
let mut by_id: BTreeMap<String, Vec<(&'a SourcePath, &'a str)>> = BTreeMap::new();
for (path, text) in input.topics {
if let Some(id) = topic_id(text) {
by_id.entry(id).or_default().push((path, text.as_str()));
}
}
let wanted: BTreeSet<&String> = ids.iter().collect();
let unknown: Vec<String> =
wanted.iter().filter(|id| !by_id.contains_key(id.as_str())).map(|id| (*id).clone()).collect();
if !unknown.is_empty() {
return Err(PackError::UnknownTopics { ids: unknown });
}
let mut selected = BTreeMap::new();
for id in wanted {
let files = &by_id[id.as_str()];
if files.len() > 1 {
return Err(PackError::DuplicateTopic {
id: id.clone(),
files: files.iter().map(|(p, _)| (*p).clone()).collect(),
});
}
selected.insert(id.clone(), files[0]);
}
let mut hash = blake3::Hasher::new();
for (path, text) in input.sources.iter() {
hash.update(&(path.as_str().len() as u64).to_le_bytes());
hash.update(path.as_str().as_bytes());
hash.update(&(text.len() as u64).to_le_bytes());
hash.update(text.as_bytes());
}
let source_hash = ContentHash::parse(&format!("blake3:{}", hash.finalize().to_hex()))
.expect("BLAKE3 emits a valid full hash");
Ok(Self { input, options, selected, source_hash })
}
fn sections(&self) -> Vec<Section> {
let dense = Dense::new(self.input.codebase);
self.selected
.iter()
.map(|(id, (file, text))| Section { id: id.clone(), text: self.section(&dense, id, file, text) })
.collect()
}
fn section(&self, dense: &Dense<'_>, id: &str, file: &SourcePath, text: &str) -> String {
let cb = self.input.codebase;
let mut found: BTreeSet<&SymbolId> = BTreeSet::new();
let mut unresolved = String::new();
let mut seen = BTreeSet::new();
let mut cited: Vec<_> = citations(text);
cited.sort_by(|a, b| a.text.cmp(&b.text));
for c in &cited {
if !seen.insert(c.text.as_str()) {
continue;
}
match &c.id {
Err(e) => {
let _ = writeln!(unresolved, "invalid {}: {e}", c.text);
}
Ok(sym) => match resolve(cb, sym, None) {
Resolution::Found { .. } => {
if let Some((key, _)) = cb.symbols.get_key_value(sym) {
found.insert(key);
}
}
Resolution::Unparsable { file } => {
let _ = writeln!(unresolved, "unparsable {sym} {file}");
}
Resolution::Absent { .. } => {
let _ = writeln!(unresolved, "absent {sym}");
}
},
}
}
let mut out = String::new();
block(&mut out, &format!("topic {id}"), Some(file.as_str()), text);
if !found.is_empty() {
let slice = dense
.slice(found.iter().copied(), &SliceOptions::new(self.options.depth))
.expect("seeds are symbols of the codebase and no budget is set");
block(&mut out, &format!("dense {id} depth {}", self.options.depth), None, &slice);
}
if !unresolved.is_empty() {
block(&mut out, &format!("unresolved {id}"), None, &unresolved);
}
if self.options.source_window > 0 {
for sym in &found {
if let Some(s) = cb.symbol(sym) {
let (label, window) = self.window(&s.file, s.span.start_line, s.span.end_line);
block(&mut out, &format!("source {id} {label}"), Some(sym.as_str()), &window);
}
}
}
let _ = writeln!(out, "==== end {id}");
out
}
fn window(&self, file: &SourcePath, start: u32, end: u32) -> (String, String) {
let Some(text) = self.input.sources.get(file) else {
return (format!("{file}:L{start}-{end} (no source text)"), String::new());
};
let lines: Vec<&str> = text.split_inclusive('\n').collect();
let start = start.max(1) as usize;
let end = (end as usize).max(start);
let shown_end = end.min(start + self.options.source_window - 1).min(lines.len());
if start > lines.len() {
return (format!("{file}:L{start}-{end} (past the end of the file)"), String::new());
}
let label = if shown_end < end {
format!("{file}:L{start}-{shown_end} of L{start}-{end}")
} else {
format!("{file}:L{start}-{end}")
};
(label, lines[start - 1..shown_end].concat())
}
fn assemble(&self, sections: &[Section], shard: Option<usize>) -> Pack {
let ids: Vec<&str> = sections.iter().map(|s| s.id.as_str()).collect();
let mut text = self.header(&ids, shard);
let header = text.len();
let mut topics = Vec::with_capacity(sections.len());
for s in sections {
text.push_str(&s.text);
topics.push(TopicSize { id: s.id.clone(), bytes: s.text.len() });
}
Pack { text, header, topics }
}
fn header(&self, ids: &[&str], shard: Option<usize>) -> String {
let input = self.input;
let mut text = format!("# sealmap-pack {PACK_FORMAT_VERSION}\ngenerator: {GENERATOR}\n");
let _ = writeln!(text, "codebase: {}", one_line(&input.codebase.name));
let _ = writeln!(text, "revision: {}", one_line(input.revision));
if let Some(base) = input.diff {
let _ = writeln!(text, "diff: {}", one_line(base));
}
let _ = writeln!(text, "topics: {}", ids.join(" "));
match self.options.budget {
Some(b) => {
let _ = writeln!(text, "budget: {b} bytes");
}
None => text.push_str("budget: none\n"),
}
let _ = writeln!(text, "depth: {}", self.options.depth);
let _ = writeln!(text, "source-window: {} lines", self.options.source_window);
let _ = writeln!(text, "source-inputs: {}", self.source_hash);
if let Some(provenance) = input.provenance {
let _ = writeln!(text, "provenance: {}", one_line(provenance));
}
if let Some(n) = shard {
let _ = writeln!(text, "shard: {n}");
}
text
}
}
fn block(out: &mut String, head: &str, tail: Option<&str>, payload: &str) {
let _ = write!(out, "==== {head} {} bytes", payload.len());
if let Some(tail) = tail {
let _ = write!(out, " {}", one_line(tail));
}
out.push('\n');
out.push_str(payload);
if !payload.is_empty() && !payload.ends_with('\n') {
out.push('\n');
}
}
fn one_line(s: &str) -> String {
s.replace(['\n', '\r'], " ")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn blocks_count_payload_bytes_only() {
let mut out = String::new();
block(&mut out, "topic A-01", Some("a b.md"), "x\ny");
assert_eq!(out, "==== topic A-01 3 bytes a b.md\nx\ny\n");
let mut out = String::new();
block(&mut out, "unresolved A-01", None, "absent x\n");
assert_eq!(out, "==== unresolved A-01 9 bytes\nabsent x\n");
}
}