use crate::manifest::AgentInfo;
use crate::{bundle, collect, identity, journal, manifest, routes, scrub, state, trust};
use std::path::PathBuf;
pub fn find_manifest(id: &str) -> Option<manifest::Manifest> {
let home = identity::home();
for stage in [routes::accepted_dir(&home), routes::pending_dir(&home)] {
let d = stage.join(id);
if d.is_dir()
&& let Ok(l) = bundle::load(&d)
{
return Some(l.manifest);
}
}
None
}
pub fn all_manifests() -> Vec<manifest::Manifest> {
let home = identity::home();
if let Err(e) = routes::observe_inbox() {
eprintln!(
"gage: WARNING -- inbox not recorded ({e}); verdicts below may read milder than the truth"
);
}
let mut out = Vec::new();
for stage in [
routes::accepted_dir(&home),
routes::pending_dir(&home),
routes::sent_dir(&home),
] {
for (_, l) in routes::list(&stage) {
out.push(l.manifest);
}
}
out.sort_by(|a, b| a.id.cmp(&b.id));
out.dedup_by(|a, b| a.id == b.id);
out
}
pub struct ReplyTarget {
pub parent: String,
pub to: String,
pub msg_type: String,
pub subject: String,
}
pub fn refuse_out_in_records(
out: &std::path::Path,
why: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let home = identity::home();
let abs = if out.is_absolute() {
out.to_path_buf()
} else {
std::env::current_dir()?.join(out)
};
let mut norm = PathBuf::new();
for c in abs.components() {
match c {
std::path::Component::ParentDir => {
norm.pop();
}
std::path::Component::CurDir => {}
other => norm.push(other),
}
}
for reserved in ["inbox", "sent", "outbox"] {
if norm.starts_with(home.join(reserved)) {
return Err(format!(
"--out may not write inside {}: that is where this machine records \
what ARRIVED and what it sent. {why}",
home.join(reserved).display()
)
.into());
}
}
Ok(())
}
pub fn resolve_reply(id: &str) -> Result<ReplyTarget, Box<dyn std::error::Error>> {
let me = identity::Identity::load()?.public_b64();
let st = state::LocalState::load();
let ts = state::threads(all_manifests(), &st, &me);
let by_msg: Vec<(&state::Thread, &manifest::Manifest)> = ts
.iter()
.flat_map(|t| t.messages.iter().map(move |m| (t, m)))
.filter(|(_, m)| m.id.starts_with(id))
.collect();
let (thread, parent) = if !by_msg.is_empty() {
if by_msg.len() > 1 {
let ids: Vec<&str> = by_msg.iter().map(|(_, m)| m.id.as_str()).collect();
return Err(format!(
"{id} matches {} messages: {}. Use more characters.",
ids.len(),
ids.join(", ")
)
.into());
}
let (t, m) = by_msg[0];
(t, m.clone())
} else {
let hits: Vec<&state::Thread> = ts.iter().filter(|t| t.id.starts_with(id)).collect();
let t = match hits.len() {
0 => return Err(format!("no thread or message starting with {id}").into()),
1 => hits[0],
_ => {
let ids: Vec<&str> = hits.iter().map(|t| t.id.as_str()).collect();
return Err(
format!("{id} matches {} threads: {}", ids.len(), ids.join(", ")).into(),
);
}
};
if t.others.len() > 1 {
let contacts = trust::Contacts::load();
let who: Vec<String> = t
.others
.iter()
.map(|k| match contacts.name_any(k) {
Some(n) => format!("{n} ({}…)", k.chars().take(10).collect::<String>()),
None => format!(
"an unlisted key ({}…)",
k.chars().take(10).collect::<String>()
),
})
.collect();
let newest: Vec<String> = t
.messages
.iter()
.rev()
.filter(|m| m.from.key != me)
.take(2)
.map(|m| format!("{} from {}", m.id, m.from.handle))
.collect();
return Err(format!(
"thread {} has {} correspondents ({}), so naming the thread does not \
name a recipient. Reply to one MESSAGE instead:\n {}",
t.id,
t.others.len(),
who.join(", "),
newest.join("\n ")
)
.into());
}
let parent = t
.messages
.iter()
.rev()
.find(|m| m.from.key != me)
.ok_or_else(|| {
format!(
"every message in thread {} is your own, so there is nobody to reply \
to. Name a message id, or use gage send --reply-to.",
t.id
)
})?
.clone();
(t, parent)
};
if parent.from.key == me {
return Err(format!(
"{} is your own message, so there is nobody to reply to. Name the \
message you are answering, or use gage send --reply-to.",
parent.id
)
.into());
}
let contacts = trust::Contacts::load();
let to = match contacts.name_for(&parent.from.key) {
Some(n) => n.to_string(),
None => {
let short: String = parent.from.key.chars().take(16).collect();
return Err(match contacts.retired_record(&parent.from.key) {
Some(r) => format!(
"that message was signed with a key you retired on {} ({short}…). \
Get their current key before replying.",
r.retired_at
),
None => format!(
"the key that signed that message is not on your list ({short}…). It \
claims the name {:?}, which proves nothing. Add them first.",
parent.from.handle
),
}
.into());
}
};
let msg_type = if parent.msg_type.expects_reply() {
"answer"
} else {
"share"
}
.to_string();
let subject = if thread.subject.to_ascii_lowercase().starts_with("re:") {
thread.subject.clone()
} else {
format!("re: {}", thread.subject)
};
Ok(ReplyTarget {
parent: parent.id.clone(),
to,
msg_type,
subject,
})
}
pub struct SendArgs {
pub to: Vec<String>,
pub subject: String,
pub out: Option<PathBuf>,
pub file: Vec<PathBuf>,
pub prompt: Vec<PathBuf>,
pub decision: Vec<PathBuf>,
pub memory: Vec<PathBuf>,
pub dir: Vec<PathBuf>,
pub allow_secrets: bool,
pub reply_to: Option<String>,
pub msg_type: String,
pub allow_findings: bool,
pub lan: bool,
pub agent: Option<String>,
pub model: Option<String>,
pub record_send: bool,
}
pub fn send_bundle(
a: SendArgs,
say: &mut Vec<String>,
) -> Result<manifest::Manifest, Box<dyn std::error::Error>> {
let SendArgs {
to,
subject,
out,
file,
prompt,
decision,
memory,
dir,
allow_secrets,
reply_to,
msg_type: type_in,
allow_findings,
lan,
agent,
model,
record_send,
} = a;
let r#type = type_in;
let id = identity::Identity::load()?;
let c = collect::collect(&file, &prompt, &decision, &memory, &dir, allow_secrets)?;
if !c.skipped_secret.is_empty() {
say.push("skipped (look like secrets, --allow-secrets to include):".to_string());
for f in &c.skipped_secret {
say.push(format!(" {f}"));
}
}
if !c.skipped_link.is_empty() {
say.push("skipped (symlinks -- a link's name says nothing about its target):".to_string());
for f in &c.skipped_link {
say.push(format!(" {f}"));
}
}
if !c.skipped_big.is_empty() {
say.push("skipped (over the per-item size limit):".to_string());
for f in &c.skipped_big {
say.push(format!(" {f}"));
}
}
if !c.skipped_dir.is_empty() {
let mut d = c.skipped_dir.clone();
d.sort();
d.dedup();
say.push(format!("skipped build/cache dirs: {}", d.join(", ")));
}
let routes = routes::Routes::load();
let skipped = c.skipped_secret.len() + c.skipped_big.len() + c.skipped_link.len();
let specs = c.items;
if specs.is_empty() {
if skipped > 0 {
return Err(format!(
"nothing left to send: all {skipped} item(s) were skipped \
(listed above). Rename them, pick different paths, or pass \
--allow-secrets if you are sure."
)
.into());
}
return Err("nothing to send: pass --file, --dir, --prompt, --decision or --memory".into());
}
if to.is_empty() {
return Err("no recipient: pass --to <handle|group:name>".into());
}
let findings = scrub::scan(&specs);
if !findings.is_empty() {
let mut lines = vec![format!(
"outbound scan found {} item(s) that should not leave:",
findings.len()
)];
for f in &findings {
lines.push(format!(
" {}:{} [{}] {}",
f.path, f.line, f.rule, f.preview
));
}
if !allow_findings {
lines.push(format!(
"refusing to build this bundle. Fix the files, add a rule to {}, \
or pass --allow-findings if they are false positives.",
crate::scrub::policy_path().display()
));
return Err(lines.join("\n").into());
}
for l in &lines {
say.push(l.to_string());
}
say.push(" --allow-findings given: sending anyway".to_string());
}
let msg_type = manifest::MsgType::parse(&r#type).ok_or_else(|| {
format!(
"unknown --type {type_in:?}; use share, question, answer, review-request or ack",
type_in = r#type
)
})?;
let thread = reply_to.as_ref().map(|rid| {
find_manifest(rid)
.map(|m| m.thread_id().to_string())
.unwrap_or_else(|| rid.clone())
});
let ag = agent.map(|name| AgentInfo { name, model });
if let Some(p) = &out {
refuse_out_in_records(
p,
"A bundle you built is not an arrival, and putting it there would make it \
indistinguishable from one. Write it anywhere else and hand it over, or drop \
--out and let gage deliver it.",
)?;
}
let stage = out
.clone()
.unwrap_or_else(|| identity::home().join(format!("outbox/{}", std::process::id())));
let m = bundle::create(
&stage, &id, &subject, &to, ag, reply_to, thread, msg_type, &specs,
)?;
let sent = routes::sent_dir(&identity::home()).join(&m.id);
if !sent.exists() {
if let Some(parent) = sent.parent() {
std::fs::create_dir_all(parent)?;
}
routes::deliver(&stage, &sent)?;
}
let mut delivered = Vec::new();
for who in &to {
if let Some(h) = routes.home_of(who) {
let dest = routes::pending_dir(&h).join(&m.id);
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent)?;
}
routes::deliver(&stage, &dest)?;
delivered.push(format!("{who} -> {}", dest.display()));
}
}
if record_send {
let contacts = trust::Contacts::load();
let addressees: Vec<journal::Addressee> = to
.iter()
.map(|who| journal::Addressee {
named: who.clone(),
key: contacts.key_for_name(who),
routed: routes.home_of(who).is_some(),
})
.collect();
let manifest_bytes = std::fs::read(sent.join("manifest.json"))
.or_else(|_| std::fs::read(stage.join("manifest.json")))
.unwrap_or_default();
if let Err(e) = journal::record_sent(&m, &manifest_bytes, addressees) {
say.push(format!(
" warning: the bundle went but the send journal did not record it: {e}"
));
}
}
say.push(format!(
"bundle {} [{}] -- {} item(s), {} bytes, signed by {}",
m.id,
m.msg_type.label(),
m.items.len(),
m.total_bytes(),
id.handle
));
if m.memory_count() > 0 {
say.push(format!(
" {} MEMORY item(s): these persist in whoever accepts them",
m.memory_count()
));
}
for d in &delivered {
say.push(format!(" delivered {d}"));
}
let unrouted: Vec<String> = to
.iter()
.filter(|w| routes.home_of(w).is_none())
.cloned()
.collect();
if !unrouted.is_empty() && !lan {
say.push(format!(" no local route for: {}", unrouted.join(", ")));
say.push(format!(
" bundle is at {} -- move it yourself, or",
stage.display()
));
say.push(" gage route <handle> <their-gage-home> to deliver automatically".to_string());
}
Ok(m)
}