use std::collections::VecDeque;
use std::fmt;
use crate::diag::{Diagnostic, Origin, Rule};
use crate::manifest::{Manifest, ManifestRejection};
use crate::mcp::{McpConfig, McpDisabledReason};
use crate::path::PackagePath;
use crate::skill::{parse_skill_md, Skill};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FileKind {
File,
Directory,
Other,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DirEntry {
pub name: String,
pub kind: FileKind,
}
impl DirEntry {
pub fn new(name: impl Into<String>, kind: FileKind) -> Self {
Self { name: name.into(), kind }
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Question {
Kind(PackagePath),
Confined(PackagePath),
Read(PackagePath),
List(PackagePath),
}
impl Question {
pub fn path(&self) -> &PackagePath {
match self {
Self::Kind(p) | Self::Confined(p) | Self::Read(p) | Self::List(p) => p,
}
}
}
impl fmt::Display for Question {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Kind(p) => write!(f, "kind of {p}"),
Self::Confined(p) => write!(f, "confinement of {p}"),
Self::Read(p) => write!(f, "contents of {p}"),
Self::List(p) => write!(f, "entries of {p}"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Answer {
Kind(Option<FileKind>),
Confined(bool),
Bytes(Vec<u8>),
Entries(Vec<DirEntry>),
Unavailable {
detail: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AnswerMismatch {
pub expected: Option<&'static str>,
pub got: &'static str,
}
impl fmt::Display for AnswerMismatch {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self.expected {
Some(expected) => write!(
f,
"answer {got} does not match the pending question ({expected} expected)",
got = self.got
),
None => write!(f, "answer {} arrived with no question pending", self.got),
}
}
}
impl std::error::Error for AnswerMismatch {}
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub struct LoadedPlugin {
pub manifest: Manifest,
pub skills: Vec<Skill>,
pub mcp: McpOutcome,
pub diagnostics: Vec<Diagnostic>,
}
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub enum McpOutcome {
Absent,
Disabled(McpDisabledReason),
Configured(McpConfig),
}
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub enum Rejection {
ManifestMissing,
ManifestNotAFile,
ManifestEscapes,
Manifest(ManifestRejection),
Source {
detail: String,
},
Incomplete,
}
impl fmt::Display for Rejection {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::ManifestMissing => f.write_str("no plugin.json at the plugin root (§5.1)"),
Self::ManifestNotAFile => f.write_str("plugin.json is not a regular file"),
Self::ManifestEscapes => {
f.write_str("plugin.json resolves outside the plugin root (§4.1)")
}
Self::Manifest(rejection) => write!(f, "manifest rejected: {rejection}"),
Self::Source { detail } => write!(f, "IO source failed: {detail}"),
Self::Incomplete => f.write_str("loading finished before the machine was done"),
}
}
}
impl std::error::Error for Rejection {}
impl Rejection {
pub fn code(&self) -> &'static str {
match self {
Self::ManifestMissing => "manifest-missing",
Self::ManifestNotAFile => "manifest-not-a-file",
Self::ManifestEscapes => "manifest-escapes",
Self::Manifest(ManifestRejection::UnsupportedSchema { .. }) => "unsupported-schema",
Self::Manifest(_) => "invalid-manifest",
Self::Source { .. } => "source-error",
Self::Incomplete => "incomplete",
}
}
}
enum State {
ManifestKind,
ManifestConfined,
ManifestRead,
SkillsKind,
SkillsConfined,
SkillsList,
SkillKind {
current: String,
},
SkillConfined {
current: String,
},
SkillRead {
current: String,
},
McpKind,
McpConfined,
McpRead,
Done(Box<Result<LoadedPlugin, Rejection>>),
Poisoned,
}
pub struct Loading {
state: State,
manifest: Option<Manifest>,
skills: Vec<Skill>,
skill_queue: VecDeque<String>,
diagnostics: Vec<Diagnostic>,
}
impl Loading {
pub fn start() -> Self {
Self {
state: State::ManifestKind,
manifest: None,
skills: Vec::new(),
skill_queue: VecDeque::new(),
diagnostics: Vec::new(),
}
}
pub fn question(&self) -> Option<Question> {
let path = |s: &str| PackagePath::new(s);
Some(match &self.state {
State::ManifestKind => Question::Kind(path("plugin.json")),
State::ManifestConfined => Question::Confined(path("plugin.json")),
State::ManifestRead => Question::Read(path("plugin.json")),
State::SkillsKind => Question::Kind(path("skills")),
State::SkillsConfined => Question::Confined(path("skills")),
State::SkillsList => Question::List(path("skills")),
State::SkillKind { current } => Question::Kind(skill_md(current)),
State::SkillConfined { current } => Question::Confined(skill_md(current)),
State::SkillRead { current } => Question::Read(skill_md(current)),
State::McpKind => Question::Kind(path("mcp.json")),
State::McpConfined => Question::Confined(path("mcp.json")),
State::McpRead => Question::Read(path("mcp.json")),
State::Done(_) | State::Poisoned => return None,
})
}
pub fn answer(&mut self, answer: Answer) -> Result<(), AnswerMismatch> {
let state = std::mem::replace(&mut self.state, State::Poisoned);
let (next, verdict) = self.transition(state, answer);
self.state = next;
verdict
}
pub fn finish(self) -> Result<LoadedPlugin, Rejection> {
match self.state {
State::Done(outcome) => *outcome,
_ => Err(Rejection::Incomplete),
}
}
fn transition(&mut self, state: State, answer: Answer) -> (State, Result<(), AnswerMismatch>) {
use Answer as A;
use State as S;
let mismatch = |expected: &'static str, got: &Answer| AnswerMismatch {
expected: Some(expected),
got: match got {
A::Kind(_) => "Kind",
A::Confined(_) => "Confined",
A::Bytes(_) => "Bytes",
A::Entries(_) => "Entries",
A::Unavailable { .. } => "Unavailable",
},
};
let next = match state {
S::ManifestKind => match answer {
A::Kind(Some(FileKind::File)) => S::ManifestConfined,
A::Kind(None) => S::Done(Box::new(Err(Rejection::ManifestMissing))),
A::Kind(Some(_)) => S::Done(Box::new(Err(Rejection::ManifestNotAFile))),
A::Unavailable { detail } => S::Done(Box::new(Err(Rejection::Source { detail }))),
other => return (S::ManifestKind, Err(mismatch("Kind", &other))),
},
S::ManifestConfined => match answer {
A::Confined(true) => S::ManifestRead,
A::Confined(false) => S::Done(Box::new(Err(Rejection::ManifestEscapes))),
A::Unavailable { detail } => S::Done(Box::new(Err(Rejection::Source { detail }))),
other => return (S::ManifestConfined, Err(mismatch("Confined", &other))),
},
S::ManifestRead => match answer {
A::Bytes(bytes) => match Manifest::parse(&bytes) {
Ok((manifest, mut diags)) => {
self.manifest = Some(manifest);
self.diagnostics.append(&mut diags);
S::SkillsKind
}
Err(rejection) => S::Done(Box::new(Err(Rejection::Manifest(rejection)))),
},
A::Unavailable { detail } => S::Done(Box::new(Err(Rejection::Source { detail }))),
other => return (S::ManifestRead, Err(mismatch("Bytes", &other))),
},
S::SkillsKind => match answer {
A::Kind(Some(FileKind::Directory)) => S::SkillsConfined,
A::Kind(None) => S::McpKind, A::Kind(Some(_)) => {
self.report(
Rule::ComponentInvalid,
Origin::Skills,
"`skills` exists but is not a directory (§6.2)",
);
S::McpKind
}
A::Unavailable { detail } => {
self.report(Rule::ComponentInvalid, Origin::Skills, detail);
S::McpKind
}
other => return (S::SkillsKind, Err(mismatch("Kind", &other))),
},
S::SkillsConfined => match answer {
A::Confined(true) => S::SkillsList,
A::Confined(false) => {
self.report(
Rule::ComponentInvalid,
Origin::Skills,
"`skills` resolves outside the plugin root (§4.1)",
);
S::McpKind
}
A::Unavailable { detail } => {
self.report(Rule::ComponentInvalid, Origin::Skills, detail);
S::McpKind
}
other => return (S::SkillsConfined, Err(mismatch("Confined", &other))),
},
S::SkillsList => match answer {
A::Entries(entries) => {
self.skill_queue = entries
.into_iter()
.filter(|e| e.kind == FileKind::Directory)
.map(|e| e.name)
.collect();
self.next_skill()
}
A::Unavailable { detail } => {
self.report(Rule::ComponentInvalid, Origin::Skills, detail);
S::McpKind
}
other => return (S::SkillsList, Err(mismatch("Entries", &other))),
},
S::SkillKind { current } => match answer {
A::Kind(Some(FileKind::File)) => S::SkillConfined { current },
A::Kind(_) => self.next_skill(),
A::Unavailable { detail } => {
self.skip_skill(¤t, detail);
self.next_skill()
}
other => return (S::SkillKind { current }, Err(mismatch("Kind", &other))),
},
S::SkillConfined { current } => match answer {
A::Confined(true) => S::SkillRead { current },
A::Confined(false) => {
self.skip_skill(
¤t,
"SKILL.md resolves outside the plugin root (§4.1)".to_owned(),
);
self.next_skill()
}
A::Unavailable { detail } => {
self.skip_skill(¤t, detail);
self.next_skill()
}
other => return (S::SkillConfined { current }, Err(mismatch("Confined", &other))),
},
S::SkillRead { current } => match answer {
A::Bytes(bytes) => {
match parse_skill_md(¤t, &bytes) {
Ok((meta, body)) => self.skills.push(Skill {
directory: current.clone(),
path: PackagePath::new("skills").child(¤t),
meta,
body,
}),
Err(why) => self.skip_skill(¤t, why.to_string()),
}
self.next_skill()
}
A::Unavailable { detail } => {
self.skip_skill(¤t, detail);
self.next_skill()
}
other => return (S::SkillRead { current }, Err(mismatch("Bytes", &other))),
},
S::McpKind => match answer {
A::Kind(Some(FileKind::File)) => S::McpConfined,
A::Kind(None) => self.done(McpOutcome::Absent),
A::Kind(Some(_)) => {
self.report(
Rule::ComponentInvalid,
Origin::Mcp,
"`mcp.json` exists but is not a regular file (§6.2)",
);
self.done(McpOutcome::Disabled(McpDisabledReason::NotAFile))
}
A::Unavailable { detail } => {
let reason = McpDisabledReason::Unavailable { detail };
self.report(Rule::McpDisabled, Origin::Mcp, reason.to_string());
self.done(McpOutcome::Disabled(reason))
}
other => return (S::McpKind, Err(mismatch("Kind", &other))),
},
S::McpConfined => match answer {
A::Confined(true) => S::McpRead,
A::Confined(false) => {
self.report(
Rule::ComponentInvalid,
Origin::Mcp,
"`mcp.json` resolves outside the plugin root (§4.1)",
);
self.done(McpOutcome::Disabled(McpDisabledReason::LocationEscapes))
}
A::Unavailable { detail } => {
let reason = McpDisabledReason::Unavailable { detail };
self.report(Rule::McpDisabled, Origin::Mcp, reason.to_string());
self.done(McpOutcome::Disabled(reason))
}
other => return (S::McpConfined, Err(mismatch("Confined", &other))),
},
S::McpRead => match answer {
A::Bytes(bytes) => {
let spec =
self.manifest.as_ref().expect("manifest parsed before MCP phase").spec;
match McpConfig::parse(&bytes, spec) {
Ok((config, mut diags)) => {
self.diagnostics.append(&mut diags);
self.done(McpOutcome::Configured(config))
}
Err(reason) => {
self.report(Rule::McpDisabled, Origin::Mcp, reason.to_string());
self.done(McpOutcome::Disabled(reason))
}
}
}
A::Unavailable { detail } => {
let reason = McpDisabledReason::Unavailable { detail };
self.report(Rule::McpDisabled, Origin::Mcp, reason.to_string());
self.done(McpOutcome::Disabled(reason))
}
other => return (S::McpRead, Err(mismatch("Bytes", &other))),
},
S::Done(outcome) => {
let got = mismatch("nothing", &answer);
return (S::Done(outcome), Err(AnswerMismatch { expected: None, got: got.got }));
}
S::Poisoned => unreachable!("Loading state invariant broken"),
};
(next, Ok(()))
}
fn next_skill(&mut self) -> State {
match self.skill_queue.pop_front() {
Some(current) => State::SkillKind { current },
None => State::McpKind,
}
}
fn report(&mut self, rule: Rule, origin: Origin, message: impl Into<String>) {
self.diagnostics.push(Diagnostic::new(rule, origin, message));
}
fn skip_skill(&mut self, directory: &str, why: String) {
self.report(
Rule::SkillSkipped,
Origin::Skill(directory.to_owned()),
format!("skill skipped (§7.1): {why}"),
);
}
fn done(&mut self, mcp: McpOutcome) -> State {
State::Done(Box::new(Ok(LoadedPlugin {
manifest: self.manifest.take().expect("manifest parsed before completion"),
skills: std::mem::take(&mut self.skills),
mcp,
diagnostics: std::mem::take(&mut self.diagnostics),
})))
}
}
fn skill_md(directory: &str) -> PackagePath {
PackagePath::new("skills").child(directory).child("SKILL.md")
}
impl fmt::Debug for Loading {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Loading")
.field("pending", &self.question())
.field("skills_loaded", &self.skills.len())
.field("diagnostics", &self.diagnostics.len())
.finish_non_exhaustive()
}
}