use std::collections::BTreeSet;
use std::io::{self, IsTerminal, Write};
use std::path::{Path, PathBuf};
use std::sync::mpsc;
use std::thread;
use std::time::Duration;
use aisling::{Loader, LoaderConfig, LoaderKind, LoaderProgress};
use crossterm::{
cursor,
event::{self, Event, KeyCode, KeyEventKind},
execute,
terminal::{Clear, ClearType},
};
use paperforge::{
ArchiveOptions, ArchiveProgress, ArchiveProgressPhase, ArchivedPaper, DiscoveryResult,
PaperDocumentFormat, PaperForge, PaperQuery, PaperSummary,
};
use crate::cli::{Cli, PaperArchiveArgs};
use crate::constants::THESA_METADATA_DIR;
use crate::error::{Result, ThesaError};
use crate::manifest::write_archive_manifest;
use crate::selection::parse_indices;
use crate::terminal::{
RankedPickerEntry, TerminalSession, draw_full_frame, pick_ranked_results_tui, read_input_line,
};
use crate::types::{Mode, PaperProvider, PaperSort};
pub(crate) fn parse_paper_query(
input: &str,
provider: PaperProvider,
sort: Option<PaperSort>,
) -> Result<PaperQuery> {
resolve_paper_query(input, Some(provider), sort).map(|(query, _)| query)
}
fn resolve_paper_query(
input: &str,
provider: Option<PaperProvider>,
sort: Option<PaperSort>,
) -> Result<(PaperQuery, PaperProvider)> {
let mut query = paperforge::parse_paper_query(input)
.map_err(|error| ThesaError::InvalidTarget(error.to_string()))?;
let query_provider = query.provider.map(|provider| match provider {
paperforge::PaperProvider::Arxiv => PaperProvider::Arxiv,
paperforge::PaperProvider::EuropePmc => PaperProvider::EuropePmc,
paperforge::PaperProvider::NasaNtrs => PaperProvider::NasaNtrs,
});
let provider = match (provider, query_provider) {
(Some(cli_provider), Some(query_provider)) if cli_provider != query_provider => {
return Err(ThesaError::InvalidTarget(format!(
"query provider '{}' conflicts with --provider '{}'",
query_provider.slug(),
cli_provider.slug()
)));
}
(Some(provider), _) | (None, Some(provider)) => provider,
(None, None) => PaperProvider::Arxiv,
};
query.provider = Some(provider.into());
if let Some(sort) = sort {
query.sort = Some(sort.into());
}
Ok((query, provider))
}
pub(crate) fn run_papers_mode(
args: &mut Cli,
terminal_session: &mut Option<TerminalSession>,
) -> Result<()> {
let query_text = args.target.as_deref().unwrap_or_default().to_string();
let query = parse_paper_query(&query_text, args.paper_provider, args.paper_sort)?;
if args.output == Path::new("./archives") {
args.output = PathBuf::from("./archives/papers");
}
let forge = paperforge_client(args.paper_provider, args.paper_limit)?;
let discovery = if let Some(session) = terminal_session.as_mut() {
discover_papers_tui(
&mut session.terminal,
forge.clone(),
query.clone(),
args.paper_provider,
&query_text,
)?
} else {
forge
.discover_detailed(&query)
.map_err(|error| paperforge_error("discover", error))?
};
let mut selected = filtered_papers(discovery.papers.clone(), args.filter.as_deref());
let exact = is_exact_query(&query);
let used_tui = terminal_session.is_some();
let mut selection_confirmed = false;
if !exact && selected.len() > 1 {
if let Some(session) = terminal_session.as_mut() {
selected = pick_papers_tui(&mut session.terminal, &selected)?;
selection_confirmed = true;
} else if io::stdout().is_terminal() {
selected = pick_papers_interactively(&selected)?;
selection_confirmed = true;
}
}
*terminal_session = None;
finish_paper_archive(
&forge,
&query_text,
args.paper_provider,
&args.output,
args.skip_existing,
args.paper_max_download_bytes,
args.dry_run,
!used_tui && io::stdout().is_terminal(),
&discovery,
selected,
selection_confirmed,
)
}
pub(crate) fn run_paperforge_archive_command(args: &PaperArchiveArgs, dry_run: bool) -> Result<()> {
let (query, provider) = resolve_paper_query(&args.query, args.provider, args.sort)?;
let forge = paperforge_client(provider, args.limit)?;
let discovery = forge
.discover_detailed(&query)
.map_err(|error| paperforge_error("discover", error))?;
let mut selected = filtered_papers(discovery.papers.clone(), args.filter.as_deref());
let mut selection_confirmed = false;
if !is_exact_query(&query) && selected.len() > 1 && io::stdout().is_terminal() {
selected = pick_papers_interactively(&selected)?;
selection_confirmed = true;
}
finish_paper_archive(
&forge,
&args.query,
provider,
&args.archive_root,
args.skip_existing,
args.max_download_bytes,
dry_run || args.dry_run,
io::stdout().is_terminal(),
&discovery,
selected,
selection_confirmed,
)
}
#[allow(clippy::too_many_arguments)]
fn finish_paper_archive(
forge: &PaperForge,
query_text: &str,
provider: PaperProvider,
output: &Path,
skip_existing: bool,
max_download_bytes: u64,
dry_run: bool,
_interactive: bool,
discovery: &DiscoveryResult,
selected: Vec<PaperSummary>,
selection_confirmed: bool,
) -> Result<()> {
if selected.is_empty() {
if selection_confirmed {
println!("No papers selected; nothing to archive.");
} else {
println!("No eligible papers matched your request.");
}
print_candidate_evidence(discovery);
return Ok(());
}
if dry_run {
print_paper_plan(
query_text,
provider,
output,
skip_existing,
max_download_bytes,
discovery,
&selected,
);
return Ok(());
}
let output = output.to_path_buf();
println!(
"Archiving {} paper(s) with PaperForge to {}",
selected.len(),
output.display()
);
println!("Source: {}", provider.label());
print_candidate_evidence(discovery);
for paper in &selected {
print_paper_line(paper);
print_rights(&paper.rights, "Discovery rights");
}
io::stdout().flush()?;
let options = ArchiveOptions {
output: output.clone(),
skip_existing,
max_download_bytes,
};
let mut renderer = io::stderr().is_terminal().then(PaperProgressRenderer::new);
let mut last_plain_completed = 0usize;
let mut last_plain_bytes = 0u64;
let summary = forge
.archive_with_progress(&selected, &options, |snapshot| {
if let Some(renderer) = renderer.as_mut() {
renderer.render(&snapshot);
} else {
let completed_changed = snapshot.completed_papers != last_plain_completed;
let bytes_advanced = snapshot.bytes_written < last_plain_bytes
|| snapshot.bytes_written.saturating_sub(last_plain_bytes) >= 8 * 1024 * 1024;
if completed_changed || bytes_advanced {
print_plain_progress(&snapshot);
last_plain_completed = snapshot.completed_papers;
last_plain_bytes = snapshot.bytes_written;
}
}
})
.map_err(|error| paperforge_error("archive", error))?;
if let Some(renderer) = renderer.as_mut() {
renderer.finish();
}
let selected_ids = selected
.iter()
.map(|paper| paper.id.as_str())
.collect::<Vec<_>>();
let archived = summary
.papers
.iter()
.map(archive_record_evidence)
.collect::<Vec<_>>();
let skipped = summary
.skipped_papers
.iter()
.map(archive_record_evidence)
.collect::<Vec<_>>();
let failed_ids = summary
.failed
.iter()
.map(|failure| failure.paper_id.as_str())
.collect::<Vec<_>>();
let summary_selected_ids = summary
.selected_ids
.iter()
.map(String::as_str)
.collect::<Vec<_>>();
let complete_ids = complete_paper_manifest_ids(
&selected_ids,
&summary_selected_ids,
&archived,
&skipped,
&failed_ids,
summary.selected,
summary.archived,
summary.skipped,
summary.failed_count,
)?;
let mut manifest_count = 0usize;
let mut manifest_file_count = 0usize;
let mut manifest_total_bytes = 0u64;
for record in summary.papers.iter().chain(&summary.skipped_papers) {
if !complete_ids.contains(&record.paper_id) {
continue;
}
let manifest = write_archive_manifest(
&record.output,
&format!("paper:{}", provider.slug()),
&record.paper_id,
true,
)?;
manifest_count += 1;
manifest_file_count += manifest.archive.file_count;
manifest_total_bytes = manifest_total_bytes.saturating_add(manifest.archive.total_bytes);
println!("Final archived rights for {}:", record.paper_id);
print_rights(&record.manifest.paper.rights, "Document rights");
}
println!(
"\nCompleted: {} papers archived, {} verified-skipped, {} failed",
summary.archived, summary.skipped, summary.failed_count
);
println!(
"Documents: {} files, {} written",
summary.files_written,
format_bytes(summary.bytes_written)
);
println!(
"Manifests: {manifest_count} paper-local {THESA_METADATA_DIR}/manifest.json sidecars ({manifest_file_count} files, {manifest_total_bytes} bytes)"
);
if !summary.failed.is_empty() {
eprintln!("Failures:");
for failure in summary.failed {
eprintln!(" - {}: {}", failure.paper_id, failure.message);
}
return Err(ThesaError::Message(
"some paper downloads failed; successful and verified-skipped archives were retained"
.to_string(),
));
}
Ok(())
}
fn paperforge_client(provider: PaperProvider, discovery_limit: usize) -> Result<PaperForge> {
PaperForge::with_user_agent(
provider.into(),
concat!(
"thesa/",
env!("CARGO_PKG_VERSION"),
" (PaperForge scholarly archive; https://github.com/Tknott95/Thesa)"
),
)
.map(|forge| forge.with_discovery_limit(discovery_limit))
.map_err(|error| paperforge_error("init", error))
}
fn discover_papers_tui(
terminal: &mut scrin::Terminal,
forge: PaperForge,
query: PaperQuery,
provider: PaperProvider,
query_text: &str,
) -> Result<DiscoveryResult> {
let loading_label = format!(
"discovering scholarly papers for {query_text} with {}",
provider.label()
);
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
let result = forge.discover_detailed(&query);
let _ = tx.send(result);
});
let mut tick = 0usize;
loop {
if let Ok(result) = rx.try_recv() {
return result.map_err(|error| paperforge_error("discover", error));
}
draw_full_frame(terminal, |frame| {
crate::draw_loading_tui(frame, &loading_label, tick, Mode::Papers, None)
})?;
tick = tick.wrapping_add(1);
if event::poll(Duration::from_millis(80))? {
let Event::Key(key) = event::read()? else {
continue;
};
if key.kind == KeyEventKind::Press && key.code == KeyCode::Esc {
return Err(ThesaError::Message("aborted by user".to_string()));
}
}
}
}
fn pick_papers_tui(
terminal: &mut scrin::Terminal,
papers: &[PaperSummary],
) -> Result<Vec<PaperSummary>> {
let entries = papers
.iter()
.map(|paper| RankedPickerEntry {
provider: paper.provider.short_label().to_string(),
title: paper.title.clone(),
license: format!(
"{} / {}",
paper.rights.document_license, paper.rights.access_status
),
metrics: paper_metrics(paper),
details: vec![
("authors".to_string(), joined_or_unavailable(&paper.authors)),
("topics".to_string(), joined_or_unavailable(&paper.topics)),
(
"doi".to_string(),
paper
.doi
.clone()
.unwrap_or_else(|| "unavailable".to_string()),
),
("source".to_string(), paper.source_url.clone()),
(
"rank".to_string(),
format!("{:?} / {}", paper.relevance.tier, paper.relevance.score),
),
],
})
.collect::<Vec<_>>();
let indexes = pick_ranked_results_tui(terminal, "paper picker", "papers", &entries)?;
Ok(indexes
.into_iter()
.map(|index| papers[index].clone())
.collect())
}
fn pick_papers_interactively(papers: &[PaperSummary]) -> Result<Vec<PaperSummary>> {
println!("Discovered {} ranked papers:", papers.len());
for (index, paper) in papers.iter().enumerate() {
print!(" {:>3})", index + 1);
print_paper_line(paper);
}
println!("Select paper indexes such as 1,3,5-8, or press Enter for all.");
print!("Selection: ");
io::stdout().flush()?;
let input = read_input_line(&mut io::stdin().lock())?;
let input = input.trim();
if input.is_empty() || input.eq_ignore_ascii_case("all") {
return Ok(papers.to_vec());
}
let indexes = parse_indices(input, papers.len()).map_err(ThesaError::Message)?;
Ok(indexes
.into_iter()
.map(|index| papers[index].clone())
.collect())
}
fn print_paper_plan(
query: &str,
provider: PaperProvider,
output: &Path,
skip_existing: bool,
max_download_bytes: u64,
discovery: &DiscoveryResult,
papers: &[PaperSummary],
) {
println!(
"Dry run: {} paper(s) selected from bounded PaperForge discovery",
papers.len()
);
println!("Provider: {}", provider.label());
println!("Query: {query}");
println!("Output: {}", output.display());
println!("Skip existing: {skip_existing} (verification occurs only during archive)");
println!("Maximum document bytes: {max_download_bytes}");
print_candidate_evidence(discovery);
for paper in papers {
print_paper_line(paper);
println!(
" ranking: tier={:?}, score={}, matched={}",
paper.relevance.tier,
paper.relevance.score,
joined_or_unavailable(&paper.relevance.matched)
);
print_rights(&paper.rights, "Rights");
if exceeds_download_limit(paper, max_download_bytes) {
println!(
" archive outcome: will fail this paper because its advertised size exceeds the {max_download_bytes} byte limit"
);
}
}
}
fn print_candidate_evidence(discovery: &DiscoveryResult) {
println!(
"Candidates: {} provider records, {} eligible, {} ranked output; budget {}",
discovery.candidate_count,
discovery.eligible_candidate_count,
discovery.papers.len(),
discovery.candidate_budget
);
if discovery.candidate_cap_reached {
println!(
"Warning: the bounded {}-candidate provider budget was reached; matching papers may exist beyond this discovery window.",
discovery.candidate_budget
);
}
}
fn print_paper_line(paper: &PaperSummary) {
println!(
" - [{}] {} ({}, citations {}, {}, {}) | {}",
paper.provider.short_label(),
paper.title,
paper.published.as_deref().unwrap_or("date unavailable"),
citation_label(paper),
document_format(paper.document.format),
paper
.document
.size
.map(format_bytes)
.unwrap_or_else(|| "size unavailable".to_string()),
paper.source_url,
);
println!(
" authors: {} | topics: {} | DOI: {}",
joined_or_unavailable(&paper.authors),
joined_or_unavailable(&paper.topics),
paper.doi.as_deref().unwrap_or("unavailable")
);
}
fn print_rights(rights: &paperforge::PaperRights, label: &str) {
println!(
" {label}: metadata={} | document={} | access={} | attribution={}",
rights.metadata_license, rights.document_license, rights.access_status, rights.attribution
);
println!(
" Redistribution warning: {}",
rights.redistribution_warning
);
}
fn filtered_papers(papers: Vec<PaperSummary>, filter: Option<&str>) -> Vec<PaperSummary> {
papers
.into_iter()
.filter(|paper| paper_matches_filter(paper, filter))
.collect()
}
fn paper_matches_filter(paper: &PaperSummary, filter: Option<&str>) -> bool {
let Some(filter) = filter.map(str::trim).filter(|value| !value.is_empty()) else {
return true;
};
let filter = filter.to_ascii_lowercase();
paper.id.to_ascii_lowercase().contains(&filter)
|| paper.title.to_ascii_lowercase().contains(&filter)
|| paper.abstract_text.to_ascii_lowercase().contains(&filter)
|| paper
.rights
.document_license
.to_ascii_lowercase()
.contains(&filter)
|| paper
.doi
.as_ref()
.is_some_and(|doi| doi.to_ascii_lowercase().contains(&filter))
|| paper
.journal
.as_ref()
.is_some_and(|journal| journal.to_ascii_lowercase().contains(&filter))
|| paper
.authors
.iter()
.chain(&paper.topics)
.any(|value| value.to_ascii_lowercase().contains(&filter))
}
fn is_exact_query(query: &PaperQuery) -> bool {
query.identities.len() == 1 && query.clauses.is_empty()
}
fn exceeds_download_limit(paper: &PaperSummary, max_download_bytes: u64) -> bool {
max_download_bytes > 0
&& paper
.document
.size
.is_some_and(|size| size > max_download_bytes)
}
fn archive_record_evidence(record: &ArchivedPaper) -> (&str, &str, bool) {
(
record.paper_id.as_str(),
record.manifest.paper.id.as_str(),
record.manifest.complete,
)
}
#[allow(clippy::too_many_arguments)]
fn complete_paper_manifest_ids(
selected_ids: &[&str],
summary_selected_ids: &[&str],
archived_records: &[(&str, &str, bool)],
skipped_records: &[(&str, &str, bool)],
failed_ids: &[&str],
selected_count: usize,
archived_count: usize,
skipped_count: usize,
failed_count: usize,
) -> Result<BTreeSet<String>> {
let selected = string_set(selected_ids);
let summary_selected = string_set(summary_selected_ids);
let archived = archived_records
.iter()
.map(|record| record.0)
.collect::<Vec<_>>();
let skipped = skipped_records
.iter()
.map(|record| record.0)
.collect::<Vec<_>>();
let archived_set = string_set(&archived);
let skipped_set = string_set(&skipped);
let failed = string_set(failed_ids);
let records_valid = archived_records
.iter()
.chain(skipped_records)
.all(|(record_id, manifest_id, complete)| record_id == manifest_id && *complete);
let counts_match = selected_count == selected_ids.len()
&& selected.len() == selected_ids.len()
&& summary_selected_ids.len() == selected_ids.len()
&& summary_selected == selected
&& archived_count == archived_records.len()
&& archived_set.len() == archived_records.len()
&& skipped_count == skipped_records.len()
&& skipped_set.len() == skipped_records.len()
&& failed_count == failed_ids.len()
&& failed.len() == failed_ids.len()
&& selected_count == archived_count + skipped_count + failed_count;
if !records_valid
|| !counts_match
|| !archived_set.is_subset(&selected)
|| !skipped_set.is_subset(&selected)
|| !failed.is_subset(&selected)
|| !archived_set.is_disjoint(&skipped_set)
|| !archived_set.is_disjoint(&failed)
|| !skipped_set.is_disjoint(&failed)
{
return Err(ThesaError::Message(
"PaperForge returned an inconsistent archive summary; no Thesa paper manifests were written"
.to_string(),
));
}
Ok(archived_set.union(&skipped_set).cloned().collect())
}
fn string_set(values: &[&str]) -> BTreeSet<String> {
values.iter().map(|value| (*value).to_string()).collect()
}
fn paper_metrics(paper: &PaperSummary) -> String {
format!(
"{} | citations {} | {} | {}",
paper.published.as_deref().unwrap_or("date unavailable"),
citation_label(paper),
document_format(paper.document.format),
paper
.document
.size
.map(format_bytes)
.unwrap_or_else(|| "size unavailable".to_string())
)
}
fn citation_label(paper: &PaperSummary) -> String {
paper
.citation_count
.map(|count| count.to_string())
.unwrap_or_else(|| "unavailable".to_string())
}
fn document_format(format: PaperDocumentFormat) -> &'static str {
match format {
PaperDocumentFormat::Pdf => "PDF",
PaperDocumentFormat::JatsXml => "JATS XML",
}
}
fn joined_or_unavailable(values: &[String]) -> String {
if values.is_empty() {
"unavailable".to_string()
} else {
values.join(", ")
}
}
fn paperforge_error(context: &str, error: impl std::fmt::Display) -> ThesaError {
ThesaError::Message(format!("paperforge {context} failed: {error}"))
}
struct PaperProgressRenderer {
stderr: io::Stderr,
loader: Loader,
tick: usize,
wrote: bool,
}
impl PaperProgressRenderer {
fn new() -> Self {
let mut stderr = io::stderr();
let _ = execute!(stderr, cursor::Hide);
Self {
stderr,
loader: Loader::with_config(
LoaderKind::Tqdm,
LoaderConfig::default()
.with_width(28)
.with_label("paperforge")
.with_unit("papers")
.with_fraction(true),
),
tick: 0,
wrote: false,
}
}
fn render(&mut self, progress: &ArchiveProgress) {
let total = progress.total_papers.max(1) as u64;
let current = progress.completed_papers.min(progress.total_papers) as u64;
let loader = self
.loader
.frame(self.tick, LoaderProgress::from_counts(current, total))
.to_ansi_string();
let active = progress.active_paper.as_deref().unwrap_or("paperforge");
let file = progress.active_file.as_deref().unwrap_or("finalizing");
let line = format!(
"{loader} | {} | {} / {}",
format_active_bytes(progress),
truncate(active, 34),
truncate(file, 26)
);
let _ = execute!(
self.stderr,
cursor::MoveToColumn(0),
Clear(ClearType::CurrentLine)
);
let _ = write!(self.stderr, "{line}");
let _ = self.stderr.flush();
self.tick = self.tick.wrapping_add(1);
self.wrote = true;
}
fn finish(&mut self) {
if self.wrote {
let _ = writeln!(self.stderr);
}
let _ = execute!(self.stderr, cursor::Show);
let _ = self.stderr.flush();
}
}
impl Drop for PaperProgressRenderer {
fn drop(&mut self) {
let _ = execute!(self.stderr, cursor::Show);
}
}
fn print_plain_progress(progress: &ArchiveProgress) {
if progress.phase == ArchiveProgressPhase::Starting {
return;
}
if let (Some(paper), Some(file)) = (&progress.active_paper, &progress.active_file) {
println!(
"PaperForge downloading {}/{}: {}",
truncate(paper, 64),
truncate(file, 48),
format_active_bytes(progress)
);
return;
}
println!(
"PaperForge {}/{} papers (successful-or-skipped={}, failed={}, bytes={})",
progress.completed_papers,
progress.total_papers,
progress.succeeded_papers,
progress.failed_papers,
format_bytes(progress.bytes_written)
);
}
fn format_active_bytes(progress: &ArchiveProgress) -> String {
progress.active_total_bytes.map_or_else(
|| format_bytes(progress.bytes_written),
|total| {
format!(
"{}/{}",
format_bytes(progress.active_bytes_written),
format_bytes(total)
)
},
)
}
fn format_bytes(bytes: u64) -> String {
const UNITS: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"];
let mut value = bytes as f64;
let mut unit = 0usize;
while value >= 1024.0 && unit + 1 < UNITS.len() {
value /= 1024.0;
unit += 1;
}
if unit == 0 {
format!("{bytes} B")
} else {
format!("{value:.2} {}", UNITS[unit])
}
}
fn truncate(value: &str, max_chars: usize) -> String {
if value.chars().count() <= max_chars {
value.to_string()
} else {
format!(
"{}...",
value
.chars()
.take(max_chars.saturating_sub(3))
.collect::<String>()
)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parser_applies_provider_and_only_explicit_sort_override() {
let query = parse_paper_query("graph @sort:date", PaperProvider::Arxiv, None).unwrap();
assert_eq!(query.provider, Some(paperforge::PaperProvider::Arxiv));
assert_eq!(query.sort, Some(paperforge::PaperSort::Date));
let query = parse_paper_query(
"graph @sort:date",
PaperProvider::EuropePmc,
Some(PaperSort::Citations),
)
.unwrap();
assert_eq!(query.provider, Some(paperforge::PaperProvider::EuropePmc));
assert_eq!(query.sort, Some(paperforge::PaperSort::Citations));
}
#[test]
fn provider_resolution_uses_directives_and_rejects_conflicts() {
let (query, provider) =
resolve_paper_query("graph @provider:europe-pmc", None, None).unwrap();
assert_eq!(provider, PaperProvider::EuropePmc);
assert_eq!(query.provider, Some(paperforge::PaperProvider::EuropePmc));
let (_, provider) = resolve_paper_query(
"graph @provider:europe-pmc",
Some(PaperProvider::EuropePmc),
None,
)
.unwrap();
assert_eq!(provider, PaperProvider::EuropePmc);
let error = resolve_paper_query(
"graph @provider:europe-pmc",
Some(PaperProvider::Arxiv),
None,
)
.unwrap_err()
.to_string();
assert!(error.contains("conflicts with --provider 'arxiv'"));
let (_, provider) = resolve_paper_query("graph", None, None).unwrap();
assert_eq!(provider, PaperProvider::Arxiv);
}
#[test]
fn candidate_warning_is_tied_to_provider_cap() {
let normal = DiscoveryResult {
papers: vec![],
candidate_count: 2,
eligible_candidate_count: 1,
candidate_cap_reached: false,
candidate_budget: 100,
};
let capped = DiscoveryResult {
candidate_cap_reached: true,
..normal.clone()
};
assert!(!normal.candidate_cap_reached);
assert!(capped.candidate_cap_reached);
}
#[test]
fn complete_manifest_ids_include_only_verified_records() {
let complete = complete_paper_manifest_ids(
&["fresh", "skip", "old-failed"],
&["fresh", "skip", "old-failed"],
&[("fresh", "fresh", true)],
&[("skip", "skip", true)],
&["old-failed"],
3,
1,
1,
1,
)
.unwrap();
assert_eq!(
complete,
BTreeSet::from(["fresh".to_string(), "skip".to_string()])
);
}
#[test]
fn inconsistent_or_incomplete_manifest_evidence_blocks_sidecars() {
assert!(
complete_paper_manifest_ids(
&["one"],
&["one"],
&[("one", "other", true)],
&[],
&[],
1,
1,
0,
0
)
.is_err()
);
assert!(
complete_paper_manifest_ids(
&["one"],
&["one"],
&[("one", "one", false)],
&[],
&[],
1,
1,
0,
0
)
.is_err()
);
assert!(
complete_paper_manifest_ids(&["one"], &["one"], &[], &[], &[], 1, 0, 0, 0).is_err()
);
assert!(
complete_paper_manifest_ids(
&["one"],
&["different"],
&[("one", "one", true)],
&[],
&[],
1,
1,
0,
0,
)
.is_err()
);
}
}