use std::collections::BTreeSet;
use std::path::Path;
use rto_graph::{ConsentState, OkfBundle, OkfDecision, Store};
use rto_render::okf::read;
pub struct Discovered {
pub bundle: OkfBundle,
pub state: ConsentState,
pub screened: Result<read::OkfReport, String>,
}
impl Discovered {
#[must_use]
pub fn root_key(&self) -> String {
crate::okf_root_key(&self.bundle.bundle)
}
#[must_use]
pub fn is_readable(&self) -> bool {
self.screened.is_ok()
}
#[must_use]
pub fn screen_classes(&self) -> String {
match &self.screened {
Ok(r) => {
let borrowed: Vec<&str> = r.screen_classes.iter().map(String::as_str).collect();
rto_graph::screen_fingerprint(&borrowed)
}
Err(_) => String::new(),
}
}
#[must_use]
pub fn summary(&self) -> String {
let concepts = match &self.screened {
Err(e) => return format!("unreadable: {e}"),
Ok(r) if r.concepts_quarantined > 0 || r.concepts_blocked > 0 => format!(
"{} concept(s), {} quarantined, {} blocked by the content screen [{}]",
r.concepts_read,
r.concepts_quarantined,
r.concepts_blocked,
r.screen_classes.join(", ")
),
Ok(r) => format!("{} concept(s), screened clean", r.concepts_read),
};
let contents = rto_render::okf::inspect::bundle_files(&self.bundle.bundle);
let note = if contents.is_complete() {
String::new()
} else {
format!(
"; {} {} could not be inspected, so what follows is incomplete",
contents.unreadable.len(),
if contents.unreadable.len() == 1 {
"entry"
} else {
"entries"
}
)
};
if contents.files.is_empty() {
return format!("{concepts}{note}");
}
let mut kinds: Vec<&str> = contents
.files
.iter()
.map(|f| {
if f.extension.is_empty() {
"no extension"
} else {
f.extension.as_str()
}
})
.collect();
kinds.sort_unstable();
kinds.dedup();
format!(
"{concepts}{note}; also {} file(s) that are not concepts and were not \
screened [{}]",
contents.files.len(),
kinds.join(", ")
)
}
}
pub fn referenced_peers(store: &Store) -> anyhow::Result<BTreeSet<String>> {
let mut out = BTreeSet::new();
for node in store.nodes_by_kind(&rto_graph::NodeKind::Other(
rto_graph::EXTERNAL_REF_KIND.to_owned(),
))? {
if let Some(qualified) = rto_graph::external_ref_target(&node)
&& let Some((project, _)) = rto_graph::parse_qualified(&qualified)
{
out.insert(project.to_owned());
}
}
Ok(out)
}
pub fn discovered(store: &Store, bundles: &[OkfBundle]) -> anyhow::Result<Vec<Discovered>> {
let referenced = referenced_peers(store)?;
let mut out = Vec::new();
for bundle in bundles {
if !referenced.contains(&bundle.peer) {
continue;
}
let screened = screen_bundle(&bundle.bundle);
let root = crate::okf_root_key(&bundle.bundle);
let classes = match &screened {
Ok(r) => {
let borrowed: Vec<&str> = r.screen_classes.iter().map(String::as_str).collect();
rto_graph::screen_fingerprint(&borrowed)
}
Err(_) => String::new(),
};
let state = store.okf_consent_holds(&bundle.peer, &root, &classes)?;
out.push(Discovered {
bundle: bundle.clone(),
state,
screened,
});
}
Ok(out)
}
fn screen_bundle(root: &Path) -> Result<read::OkfReport, String> {
let files = crate::read_bundle_files(root).map_err(|e| e.to_string())?;
read::read_bundle(
&root.display().to_string(),
&files,
&read::ReadOptions {
trust: read::Trust::Acknowledge,
peer: "",
extref_keys: &[],
},
)
.map(|i| i.report)
.map_err(|e| e.to_string())
}
#[must_use]
pub fn may_prompt() -> bool {
std::io::IsTerminal::is_terminal(&std::io::stdin())
}
#[must_use]
pub fn prompt_text(d: &Discovered) -> String {
let why = d
.state
.why_asking()
.unwrap_or_else(|| "not seen before".to_owned());
format!(
"\n{peer} publishes an OKF bundle, and this graph references it.\n\
\n\
bundle: {path}\n\
asking: {why}\n\
contains: {summary}\n\
\n\
Reading it puts {peer}'s prose into this graph, where the model-facing\n\
tools return it as grounding. Their confirmations are theirs, not ours.\n\
\n\
[t] trust import at `external-<their tier>`, keeping what they claimed\n\
[a] acknowledge import at `external-inferred`: their information, not their\n\
\x20 confirmation\n\
[i] ignore leave the cross-repo placeholder as it is\n\
\n",
peer = d.bundle.peer,
path = d.bundle.bundle.display(),
why = why,
summary = d.summary(),
)
}
#[must_use]
pub fn note_text(d: &Discovered, silent_because: Unasked) -> String {
format!(
"roteiro: {peer} publishes an OKF bundle at {path} ({summary}), and it is \
undecided: {why}. {because}, so it was **ignored** and nothing was recorded — a \
graph does not adopt a stranger's concepts because nobody was there to object. \
Decide it with `roteiro import --from okf {path}` (add --trust to keep their \
tiers).",
peer = d.bundle.peer,
path = d.bundle.bundle.display(),
summary = d.summary(),
why = d
.state
.why_asking()
.unwrap_or_else(|| "not seen before".to_owned()),
because = silent_because.as_str(),
)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Unasked {
NoTerminal,
NotWriting,
Unreadable,
MachineOutput,
}
impl Unasked {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::NoTerminal => "This run is not interactive",
Self::NotWriting => "This run does not write (`links` without `--write`)",
Self::Unreadable => "The bundle could not be read, so there is nothing to decide",
Self::MachineOutput => "This run emits `--json`, so there is nobody to ask",
}
}
}
pub fn ask(d: &Discovered) -> anyhow::Result<OkfDecision> {
use std::io::Write as _;
eprint!("{}", prompt_text(d));
eprint!("trust / acknowledge / ignore? [t/a/I] ");
std::io::stderr().flush().ok();
let mut answer = String::new();
std::io::stdin().read_line(&mut answer)?;
Ok(match answer.trim().to_ascii_lowercase().as_str() {
"t" | "trust" => OkfDecision::Trust,
"a" | "ack" | "acknowledge" => OkfDecision::Acknowledge,
_ => OkfDecision::Ignore,
})
}
#[derive(Debug, Default)]
pub struct Noted(BTreeSet<String>);
impl Noted {
pub fn should_note(&mut self, peer: &str) -> bool {
self.0.insert(peer.to_owned()) && self.0.len() <= NOTE_LIMIT
}
#[must_use]
pub fn suppressed(&self) -> usize {
self.0.len().saturating_sub(NOTE_LIMIT)
}
}
pub const NOTE_LIMIT: usize = 3;
#[cfg(test)]
mod tests {
use super::{NOTE_LIMIT, Noted};
#[test]
fn a_peer_is_noted_once_per_process() {
let mut noted = Noted::default();
assert!(noted.should_note("acme"));
assert!(!noted.should_note("acme"), "a reload must not reprint it");
assert_eq!(noted.suppressed(), 0);
}
#[test]
fn beyond_the_limit_peers_are_counted_rather_than_named() {
let mut noted = Noted::default();
for i in 0..NOTE_LIMIT {
assert!(noted.should_note(&format!("peer{i}")));
}
assert!(!noted.should_note("one-too-many"));
assert!(!noted.should_note("two-too-many"));
assert_eq!(
noted.suppressed(),
2,
"the cap must account for what it did not print"
);
}
}