use serde_json::Value;
use crate::sandbox::paths::within;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Source {
File { path: String },
Output { call_id: String },
Attachment { name: String },
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Delegation {
pub question: String,
pub source: Source,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Refused {
pub refused: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Resolved {
pub question: String,
pub describes: String,
pub content: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Outcome<T> {
Ready(T),
Refused(Refused),
}
#[allow(
dead_code,
reason = "the tests classify an outcome the daemon matches on directly"
)]
pub fn is_refused<T>(value: &Outcome<T>) -> bool {
matches!(value, Outcome::Refused(_))
}
fn text(record: &Value, key: &str) -> Option<String> {
match record.get(key) {
Some(Value::String(value)) if !value.trim().is_empty() => Some(value.trim().to_owned()),
_ => None,
}
}
pub fn parse_delegation(raw: &Value) -> Outcome<Delegation> {
if raw.as_object().is_none() {
return Outcome::Refused(Refused {
refused: "a delegation must be an object with a question and a source".to_owned(),
});
}
let Some(question) = text(raw, "question") else {
return Outcome::Refused(Refused {
refused: "a delegation must ask a question".to_owned(),
});
};
let path = text(raw, "path");
let call_id = text(raw, "callId");
let attachment = text(raw, "attachment");
let named = [&path, &call_id, &attachment]
.iter()
.filter(|value| value.is_some())
.count();
if named == 0 {
return Outcome::Refused(Refused {
refused:
"a delegation must name what to look at: a file path, a call id, or an attachment"
.to_owned(),
});
}
if named > 1 {
return Outcome::Refused(Refused {
refused: "a delegation names one thing to look at, not several".to_owned(),
});
}
if let Some(path) = path {
return Outcome::Ready(Delegation {
question,
source: Source::File { path },
});
}
if let Some(call_id) = call_id {
return Outcome::Ready(Delegation {
question,
source: Source::Output { call_id },
});
}
Outcome::Ready(Delegation {
question,
source: Source::Attachment {
name: attachment.unwrap_or_default(),
},
})
}
pub trait Sources: Send + Sync {
fn project_root(&self) -> &str;
fn read_file(&self, path: &str) -> impl Future<Output = std::io::Result<String>> + Send;
fn output_of(&self, call_id: &str) -> Option<String>;
fn attachment(&self, name: &str) -> Option<String>;
}
pub async fn resolve_source(delegation: &Delegation, sources: &impl Sources) -> Outcome<Resolved> {
let question = delegation.question.clone();
match &delegation.source {
Source::File { path } => {
let Some(path) = within(sources.project_root(), path) else {
return Outcome::Refused(Refused {
refused: format!(
"{} is outside this session's project",
match &delegation.source {
Source::File { path } => path.clone(),
_ => String::new(),
}
),
});
};
match sources.read_file(&path).await {
Ok(content) => Outcome::Ready(Resolved {
question,
describes: match &delegation.source {
Source::File { path } => path.clone(),
_ => String::new(),
},
content,
}),
Err(_) => Outcome::Refused(Refused {
refused: format!(
"{} could not be read",
match &delegation.source {
Source::File { path } => path.clone(),
_ => String::new(),
}
),
}),
}
}
Source::Output { call_id } => match sources.output_of(call_id) {
None => Outcome::Refused(Refused {
refused: format!("no call in this session has the id {call_id}"),
}),
Some(content) => Outcome::Ready(Resolved {
question,
describes: format!("the output of {call_id}"),
content,
}),
},
Source::Attachment { name } => match sources.attachment(name) {
None => Outcome::Refused(Refused {
refused: format!("nothing called {name} is attached to this conversation"),
}),
Some(content) => Outcome::Ready(Resolved {
question,
describes: name.clone(),
content,
}),
},
}
}
#[cfg(test)]
mod tests;