370 lines
15 KiB
Rust
370 lines
15 KiB
Rust
//! Text for stdout. Display only: nothing here decides an answer.
|
|
|
|
use std::fmt::Write;
|
|
|
|
use huginn_mind::eureka_pipeline::{PipelineResolution, ResolutionOutcome};
|
|
use huginn_mind::{ChatHits, ChatWindow, SourceHits, IndexStatus, PersonaStatus, PipelineDocumentSummary, PipelineDocumentView, PipelineStatus, PipelineStatusSummary};
|
|
use serde::Serialize;
|
|
use serde_json::Value;
|
|
|
|
/// A header line is cut here, so a page of them stays one screen per dozen.
|
|
const HEADER_MAX_CHARS: usize = 240;
|
|
|
|
/// The variant or string a serde-tagged value is spelled as: `Hands` for a
|
|
/// faculty, `Fixed` for an outcome.
|
|
pub fn name_of<T: Serialize>(value: &T) -> String {
|
|
match serde_json::to_value(value) {
|
|
Ok(Value::String(name)) => name,
|
|
Ok(Value::Object(fields)) => fields.keys().next().cloned().unwrap_or_default(),
|
|
_ => String::new(),
|
|
}
|
|
}
|
|
|
|
/// The persona door's state as one line: no value of the document, only the
|
|
/// vocabulary the door answers in.
|
|
pub fn persona_state(persona: &PersonaStatus) -> String {
|
|
match persona {
|
|
PersonaStatus::Absent => "absent".into(),
|
|
PersonaStatus::Stored { updated_at } => format!("stored updatedAt={updated_at}"),
|
|
PersonaStatus::Unreadable => "unreadable".into(),
|
|
}
|
|
}
|
|
|
|
/// An index status as one token and its numbers: `Behind pending=3`.
|
|
pub fn index_state(index: &IndexStatus) -> String {
|
|
let mut text = name_of(index);
|
|
if let Ok(Value::Object(variant)) = serde_json::to_value(index)
|
|
&& let Some(Value::Object(fields)) = variant.values().next()
|
|
{
|
|
for (key, value) in fields {
|
|
if let Some(shown) = scalar(value) {
|
|
let _ = write!(text, " {key}={shown}");
|
|
}
|
|
}
|
|
}
|
|
text
|
|
}
|
|
|
|
/// One document as `view` prints it: id and status, the admission facts, then
|
|
/// the `{kind, value}` document as pretty JSON. `raw` is the JSON alone.
|
|
pub fn view(view: &PipelineDocumentView, raw: bool) -> String {
|
|
let document = serde_json::to_string_pretty(&view.document).unwrap_or_default();
|
|
if raw {
|
|
return document;
|
|
}
|
|
let id = &view.id.id.0;
|
|
let status = match &view.status {
|
|
PipelineStatus::InForce => "InForce".to_string(),
|
|
PipelineStatus::Resolved { resolution, record: PipelineResolution { outcome: ResolutionOutcome::Moved { to }, .. } } => {
|
|
format!("Resolved Moved to {} by {}", to.0, resolution.id.0)
|
|
}
|
|
PipelineStatus::Resolved { resolution, record } => format!("Resolved {} by {}", name_of(&record.outcome), resolution.id.0),
|
|
};
|
|
let admission = &view.admission;
|
|
let who = &admission.provenance;
|
|
let imported = who.imported_from.as_ref().map_or_else(String::new, |source| {
|
|
format!(
|
|
"\nimported from {} ordinal {} receipt {} ({}/{} session {})",
|
|
source.instance.0,
|
|
source.ordinal,
|
|
source.receipt_id.0,
|
|
name_of(&source.faculty),
|
|
source.agent.0,
|
|
source.session.0,
|
|
)
|
|
});
|
|
format!(
|
|
"{id} {status}\nadmitted {} ordinal {} {}/{} session {} tool {} receipt {}{imported}\n{document}",
|
|
admission.admitted_at,
|
|
admission.ordinal,
|
|
name_of(&who.faculty),
|
|
who.agent.0,
|
|
who.session.0,
|
|
who.tool.0,
|
|
admission.receipt_id,
|
|
)
|
|
}
|
|
|
|
/// One header as a line: `<id> <status> <faculty> <title or label> k=v...`
|
|
/// over the header's scalar facts.
|
|
pub fn header(item: &PipelineDocumentSummary) -> String {
|
|
let status = match &item.status {
|
|
PipelineStatusSummary::InForce => "InForce".to_string(),
|
|
PipelineStatusSummary::Resolved { outcome, .. } => format!("Resolved:{}", name_of(outcome)),
|
|
};
|
|
let mut line = format!("{} {status} {}", item.id.id.0, name_of(&item.admission.provenance.faculty));
|
|
if let Ok(facts) = serde_json::to_value(&item.facts)
|
|
&& let Some(Value::Object(facts)) = facts.get("value")
|
|
{
|
|
if let Some(Value::String(name)) = facts.get("title").or_else(|| facts.get("label")) {
|
|
let _ = write!(line, " {name}");
|
|
}
|
|
for (key, value) in facts {
|
|
if key == "authority"
|
|
&& let Some(Value::Object(steward)) = value.get("Steward")
|
|
{
|
|
// Her words stay out of the header: it names who decided and under what.
|
|
let _ = write!(line, " authority=Steward");
|
|
for field in ["steward", "repo"] {
|
|
if let Some(shown) = steward.get(field).and_then(scalar) {
|
|
let _ = write!(line, " {field}={shown}");
|
|
}
|
|
}
|
|
} else if key != "title"
|
|
&& key != "label"
|
|
&& let Some(shown) = scalar(value)
|
|
{
|
|
let _ = write!(line, " {key}={shown}");
|
|
}
|
|
}
|
|
}
|
|
line.chars().take(HEADER_MAX_CHARS).collect()
|
|
}
|
|
|
|
/// Source hits as the CLI prints them: per hit a citation line,
|
|
/// `repo:path:start-end @commit7 score`, then the chunk's text, and a blank line
|
|
/// between hits. Nothing for no hits.
|
|
pub fn source_hits(found: &SourceHits) -> String {
|
|
found
|
|
.hits
|
|
.iter()
|
|
.map(|hit| {
|
|
format!(
|
|
"{}:{}:{}-{} @{} {:.3}
|
|
{}
|
|
",
|
|
hit.repo,
|
|
hit.path,
|
|
hit.line_start,
|
|
hit.line_end,
|
|
&hit.commit[..hit.commit.len().min(7)],
|
|
hit.score,
|
|
hit.text
|
|
)
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join("
|
|
")
|
|
}
|
|
|
|
/// Chat hits as the CLI prints them. Per hit the organ's own header line,
|
|
/// `#alias (!room) first..last time..time score` (the alias and the score only
|
|
/// when there are some), then one `time sender event_id body` line per message,
|
|
/// the body as the room held it with line breaks written `\n`: quoted data
|
|
/// under our header, never a message framed as if sent to the reader. A blank line between hits; a
|
|
/// truncated listing ends with the `--after` that continues it.
|
|
pub fn chat_hits(found: &ChatHits) -> String {
|
|
let mut out: Vec<String> = found
|
|
.hits
|
|
.iter()
|
|
.map(|hit| {
|
|
let room = hit.room_alias.as_ref().map_or_else(|| hit.room_id.clone(), |alias| format!("{alias} ({})", hit.room_id));
|
|
let score = hit.score.map_or_else(String::new, |score| format!(" {score:.3}"));
|
|
let mut text = format!(
|
|
"{room} {}..{} {}..{}{score}\n",
|
|
hit.first_event_id,
|
|
hit.last_event_id,
|
|
crate::chat::shown(hit.ts_start_ms),
|
|
crate::chat::shown(hit.ts_end_ms)
|
|
);
|
|
for message in &hit.messages {
|
|
let _ = writeln!(text, "{} {} {} {}", crate::chat::shown(message.ts_ms), message.sender, message.event_id, one_line(&message.body));
|
|
}
|
|
text
|
|
})
|
|
.collect();
|
|
if found.truncated
|
|
&& let Some(last) = found.hits.last()
|
|
{
|
|
out.push(format!("truncated: more follow, continue with --after {}\n", crate::chat::shown(last.ts_end_ms + 1)));
|
|
}
|
|
out.join("\n")
|
|
}
|
|
|
|
/// A message body on one line: a line break is written `\n` (and a carriage
|
|
/// return `\r`), so a body cannot start a line of its own. `--json` has the
|
|
/// body exactly.
|
|
fn one_line(body: &str) -> String {
|
|
body.replace('\r', "\\r").replace('\n', "\\n")
|
|
}
|
|
|
|
/// A chat window as the CLI prints it: `#alias (!room)` (the id alone with no
|
|
/// alias), then one `time sender event_id body` line per message, the anchor
|
|
/// marked `>` and the rest indented to match; a line says when messages lie
|
|
/// beyond either end.
|
|
pub fn chat_window(found: &ChatWindow) -> String {
|
|
let room = found.room_alias.as_ref().map_or_else(|| found.room_id.clone(), |alias| format!("{alias} ({})", found.room_id));
|
|
let mut out = format!("{room}\n");
|
|
if found.more_before {
|
|
out.push_str(" (earlier messages exist)\n");
|
|
}
|
|
for message in &found.messages {
|
|
let mark = if message.event_id == found.anchor_event_id { '>' } else { ' ' };
|
|
let _ = writeln!(out, "{mark} {} {} {} {}", crate::chat::shown(message.ts_ms), message.sender, message.event_id, one_line(&message.body));
|
|
}
|
|
if found.more_after {
|
|
out.push_str(" (later messages exist)\n");
|
|
}
|
|
out.pop();
|
|
out
|
|
}
|
|
|
|
/// A fact as one token: a string, a number, a flag, a reference's id, or a
|
|
/// list of those joined by commas. Anything deeper is not a header fact.
|
|
fn scalar(value: &Value) -> Option<String> {
|
|
match value {
|
|
Value::String(text) if !text.is_empty() => Some(text.clone()),
|
|
Value::Number(number) => Some(number.to_string()),
|
|
Value::Bool(flag) => Some(flag.to_string()),
|
|
Value::Array(items) => {
|
|
let shown: Vec<String> = items.iter().filter_map(scalar).collect();
|
|
if shown.is_empty() { None } else { Some(shown.join(",")) }
|
|
}
|
|
Value::Object(fields) => match fields.get("id") {
|
|
Some(Value::String(id)) => Some(id.clone()),
|
|
_ => None,
|
|
},
|
|
Value::String(_) | Value::Null => None,
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use serde_json::json;
|
|
|
|
use huginn_mind::eureka_pipeline::{Date, PipelineDocument, PipelineInstance, PipelineKind, PipelineRef, Short, Slug};
|
|
use huginn_mind::{AdmissionFacts, Faculty, ImportSource, PipelineProvenance};
|
|
|
|
use super::*;
|
|
|
|
fn viewed(imported_from: Option<ImportSource>, status: PipelineStatus) -> PipelineDocumentView {
|
|
PipelineDocumentView {
|
|
id: PipelineRef { kind: PipelineKind::Instance, id: Short("heimdall:instance:self".into()) },
|
|
document: PipelineDocument::Instance(PipelineInstance {
|
|
instance: Slug("heimdall".into()),
|
|
display_name: Short("Heimdall".into()),
|
|
created_at: Date("2026-10-10".into()),
|
|
host: Short("yggdrasil".into()),
|
|
}),
|
|
admission: AdmissionFacts {
|
|
receipt_id: "mind-commit-b".into(),
|
|
admitted_at: "2026-11-08T12:00:00Z".into(),
|
|
provenance: PipelineProvenance {
|
|
faculty: Faculty::SelfFaculty,
|
|
agent: Short("heimdall-self".into()),
|
|
session: Short("run-9".into()),
|
|
tool: Short("huginn".into()),
|
|
imported_from,
|
|
},
|
|
ordinal: 4,
|
|
},
|
|
status,
|
|
}
|
|
}
|
|
|
|
/// A reader of an imported document sees where it came from and who admitted it there; a document
|
|
/// admitted here prints no source line, and a moved one names its receiver.
|
|
#[test]
|
|
fn view_header_names_the_import_source() {
|
|
let source = ImportSource {
|
|
instance: Slug("eureka".into()),
|
|
receipt_id: Short("mind-commit-a".into()),
|
|
ordinal: 7331,
|
|
committed_at: Short("2026-11-08T16:20:05Z".into()),
|
|
faculty: Faculty::Imagination,
|
|
agent: Short("imagination".into()),
|
|
session: Short("eureka-self-1108".into()),
|
|
tool: Short("huginn".into()),
|
|
external: vec![],
|
|
};
|
|
let shown = view(&viewed(Some(source), PipelineStatus::InForce), false);
|
|
assert!(
|
|
shown.contains("
|
|
imported from eureka ordinal 7331 receipt mind-commit-a (Imagination/imagination session eureka-self-1108)
|
|
"),
|
|
"{shown}"
|
|
);
|
|
assert!(!view(&viewed(None, PipelineStatus::InForce), false).contains("imported from"));
|
|
|
|
let record = huginn_mind::eureka_pipeline::PipelineResolution {
|
|
subject: PipelineRef { kind: PipelineKind::Campaign, id: Short("c:campaign:self".into()) },
|
|
sequence: 1,
|
|
outcome: ResolutionOutcome::Moved { to: Slug("heimdall".into()) },
|
|
rationale: "Her repo.".into(),
|
|
resolved_on: Date("2026-11-08".into()),
|
|
};
|
|
let resolution = PipelineRef { kind: PipelineKind::Resolution, id: Short("c:resolution:campaign.self.n1".into()) };
|
|
let moved = view(&viewed(None, PipelineStatus::Resolved { resolution, record }), false);
|
|
assert!(moved.starts_with("heimdall:instance:self Resolved Moved to heimdall by c:resolution:campaign.self.n1
|
|
"), "{moved}");
|
|
}
|
|
|
|
/// The citation line is what an agent copies: repo, path, the inclusive
|
|
/// line range and the first seven of the commit.
|
|
#[test]
|
|
fn a_source_hit_prints_as_a_citation_then_its_text() {
|
|
let hit = |path: &str, text: &str| huginn_mind::SourceHit {
|
|
repo: "Huginn".into(),
|
|
path: path.into(),
|
|
commit: "0123456789abcdef".into(),
|
|
line_start: 3,
|
|
line_end: 9,
|
|
score: 0.87654,
|
|
text: text.into(),
|
|
};
|
|
let found = SourceHits {
|
|
indexed_under: huginn_mind::SourceModel { name: "m".into(), digest: "d".into() },
|
|
hits: vec![hit("a.rs", "one"), hit("b/c.rs", "two")],
|
|
};
|
|
assert_eq!(source_hits(&found), "Huginn:a.rs:3-9 @0123456 0.877
|
|
one
|
|
|
|
Huginn:b/c.rs:3-9 @0123456 0.877
|
|
two
|
|
");
|
|
let empty = SourceHits { indexed_under: found.indexed_under.clone(), hits: vec![] };
|
|
assert_eq!(source_hits(&empty), "");
|
|
let json = serde_json::to_string(&found).unwrap();
|
|
assert_eq!(serde_json::from_str::<SourceHits>(&json).unwrap(), found, "--json round-trips");
|
|
}
|
|
|
|
#[test]
|
|
fn a_steward_ruling_header_names_the_decider_and_her_stewardship_but_not_her_words() {
|
|
use huginn_mind::eureka_pipeline::{OrgRepo, PipelineRuling, RulingAuthority};
|
|
let mut view = viewed(None, PipelineStatus::InForce);
|
|
view.document = PipelineDocument::Ruling(PipelineRuling {
|
|
campaign: Slug("c".into()),
|
|
label: "R1".into(),
|
|
title: "Zeros".into(),
|
|
answers: None,
|
|
choice: None,
|
|
ruling: "Refuse them.".into(),
|
|
operator_quote: None,
|
|
ruled_on: Date("2026-11-11".into()),
|
|
precedents: vec![],
|
|
authority: RulingAuthority::Steward {
|
|
steward: "@heimdall:chat".into(),
|
|
repo: OrgRepo("GameCult/Bifrost".into()),
|
|
words: "refuse leading zeros".into(),
|
|
event: None,
|
|
},
|
|
});
|
|
let line = header(&PipelineDocumentSummary::of(&view, &|_| None));
|
|
assert!(line.contains("authority=Steward steward=@heimdall:chat repo=GameCult/Bifrost"), "{line}");
|
|
assert!(!line.contains("zeros"), "{line}");
|
|
}
|
|
|
|
#[test]
|
|
fn a_fact_is_one_token_or_nothing() {
|
|
assert_eq!(scalar(&json!("")), None);
|
|
assert_eq!(scalar(&json!("a b")), Some("a b".to_string()));
|
|
assert_eq!(scalar(&json!(3)), Some("3".to_string()));
|
|
assert_eq!(scalar(&json!(true)), Some("true".to_string()));
|
|
assert_eq!(scalar(&json!(["a", "", "b"])), Some("a,b".to_string()));
|
|
assert_eq!(scalar(&json!([])), None);
|
|
assert_eq!(scalar(&json!(null)), None);
|
|
assert_eq!(scalar(&json!({ "kind": "Ruling", "id": "c:ruling:R1" })), Some("c:ruling:R1".to_string()));
|
|
assert_eq!(scalar(&json!({ "id": 3 })), None);
|
|
assert_eq!(scalar(&json!({ "Withdrawn": { "reason": "moot" } })), None);
|
|
}
|
|
}
|