1use std::collections::VecDeque;
33use std::fmt;
34
35use crate::diag::{Diagnostic, Origin, Rule};
36use crate::manifest::{Manifest, ManifestRejection};
37use crate::mcp::{McpConfig, McpDisabledReason};
38use crate::path::PackagePath;
39use crate::skill::{parse_skill_md, Skill};
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum FileKind {
44 File,
46 Directory,
48 Other,
50}
51
52#[derive(Debug, Clone, PartialEq, Eq)]
54pub struct DirEntry {
55 pub name: String,
57 pub kind: FileKind,
59}
60
61impl DirEntry {
62 pub fn new(name: impl Into<String>, kind: FileKind) -> Self {
64 Self { name: name.into(), kind }
65 }
66}
67
68#[derive(Debug, Clone, PartialEq, Eq)]
73pub enum Question {
74 Kind(PackagePath),
77 Confined(PackagePath),
81 Read(PackagePath),
83 List(PackagePath),
86}
87
88impl Question {
89 pub fn path(&self) -> &PackagePath {
91 match self {
92 Self::Kind(p) | Self::Confined(p) | Self::Read(p) | Self::List(p) => p,
93 }
94 }
95}
96
97impl fmt::Display for Question {
98 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
99 match self {
100 Self::Kind(p) => write!(f, "kind of {p}"),
101 Self::Confined(p) => write!(f, "confinement of {p}"),
102 Self::Read(p) => write!(f, "contents of {p}"),
103 Self::List(p) => write!(f, "entries of {p}"),
104 }
105 }
106}
107
108#[derive(Debug, Clone, PartialEq, Eq)]
110pub enum Answer {
111 Kind(Option<FileKind>),
113 Confined(bool),
115 Bytes(Vec<u8>),
117 Entries(Vec<DirEntry>),
119 Unavailable {
122 detail: String,
124 },
125}
126
127#[derive(Debug, Clone, PartialEq, Eq)]
130pub struct AnswerMismatch {
131 pub expected: Option<&'static str>,
133 pub got: &'static str,
135}
136
137impl fmt::Display for AnswerMismatch {
138 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
139 match self.expected {
140 Some(expected) => write!(
141 f,
142 "answer {got} does not match the pending question ({expected} expected)",
143 got = self.got
144 ),
145 None => write!(f, "answer {} arrived with no question pending", self.got),
146 }
147 }
148}
149
150impl std::error::Error for AnswerMismatch {}
151
152#[derive(Debug, Clone, PartialEq)]
154#[non_exhaustive]
155pub struct LoadedPlugin {
156 pub manifest: Manifest,
158 pub skills: Vec<Skill>,
160 pub mcp: McpOutcome,
162 pub diagnostics: Vec<Diagnostic>,
164}
165
166#[derive(Debug, Clone, PartialEq)]
168#[non_exhaustive]
169pub enum McpOutcome {
170 Absent,
172 Disabled(McpDisabledReason),
174 Configured(McpConfig),
176}
177
178#[derive(Debug, Clone, PartialEq)]
181#[non_exhaustive]
182pub enum Rejection {
183 ManifestMissing,
185 ManifestNotAFile,
187 ManifestEscapes,
189 Manifest(ManifestRejection),
191 Source {
193 detail: String,
195 },
196 Incomplete,
198}
199
200impl fmt::Display for Rejection {
201 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
202 match self {
203 Self::ManifestMissing => f.write_str("no plugin.json at the plugin root (§5.1)"),
204 Self::ManifestNotAFile => f.write_str("plugin.json is not a regular file"),
205 Self::ManifestEscapes => {
206 f.write_str("plugin.json resolves outside the plugin root (§4.1)")
207 }
208 Self::Manifest(rejection) => write!(f, "manifest rejected: {rejection}"),
209 Self::Source { detail } => write!(f, "IO source failed: {detail}"),
210 Self::Incomplete => f.write_str("loading finished before the machine was done"),
211 }
212 }
213}
214
215impl std::error::Error for Rejection {}
216
217impl Rejection {
218 pub fn code(&self) -> &'static str {
221 match self {
222 Self::ManifestMissing => "manifest-missing",
223 Self::ManifestNotAFile => "manifest-not-a-file",
224 Self::ManifestEscapes => "manifest-escapes",
225 Self::Manifest(ManifestRejection::UnsupportedSchema { .. }) => "unsupported-schema",
226 Self::Manifest(_) => "invalid-manifest",
227 Self::Source { .. } => "source-error",
228 Self::Incomplete => "incomplete",
229 }
230 }
231}
232
233enum State {
234 ManifestKind,
235 ManifestConfined,
236 ManifestRead,
237 SkillsKind,
238 SkillsConfined,
239 SkillsList,
240 SkillKind {
241 current: String,
242 },
243 SkillConfined {
244 current: String,
245 },
246 SkillRead {
247 current: String,
248 },
249 McpKind,
250 McpConfined,
251 McpRead,
252 Done(Box<Result<LoadedPlugin, Rejection>>),
253 Poisoned,
255}
256
257pub struct Loading {
262 state: State,
263 manifest: Option<Manifest>,
264 skills: Vec<Skill>,
265 skill_queue: VecDeque<String>,
266 diagnostics: Vec<Diagnostic>,
267}
268
269impl Loading {
270 pub fn start() -> Self {
272 Self {
273 state: State::ManifestKind,
274 manifest: None,
275 skills: Vec::new(),
276 skill_queue: VecDeque::new(),
277 diagnostics: Vec::new(),
278 }
279 }
280
281 pub fn question(&self) -> Option<Question> {
284 let path = |s: &str| PackagePath::new(s);
285 Some(match &self.state {
286 State::ManifestKind => Question::Kind(path("plugin.json")),
287 State::ManifestConfined => Question::Confined(path("plugin.json")),
288 State::ManifestRead => Question::Read(path("plugin.json")),
289 State::SkillsKind => Question::Kind(path("skills")),
290 State::SkillsConfined => Question::Confined(path("skills")),
291 State::SkillsList => Question::List(path("skills")),
292 State::SkillKind { current } => Question::Kind(skill_md(current)),
293 State::SkillConfined { current } => Question::Confined(skill_md(current)),
294 State::SkillRead { current } => Question::Read(skill_md(current)),
295 State::McpKind => Question::Kind(path("mcp.json")),
296 State::McpConfined => Question::Confined(path("mcp.json")),
297 State::McpRead => Question::Read(path("mcp.json")),
298 State::Done(_) | State::Poisoned => return None,
299 })
300 }
301
302 pub fn answer(&mut self, answer: Answer) -> Result<(), AnswerMismatch> {
304 let state = std::mem::replace(&mut self.state, State::Poisoned);
305 let (next, verdict) = self.transition(state, answer);
306 self.state = next;
307 verdict
308 }
309
310 pub fn finish(self) -> Result<LoadedPlugin, Rejection> {
313 match self.state {
314 State::Done(outcome) => *outcome,
315 _ => Err(Rejection::Incomplete),
316 }
317 }
318
319 fn transition(&mut self, state: State, answer: Answer) -> (State, Result<(), AnswerMismatch>) {
320 use Answer as A;
321 use State as S;
322
323 let mismatch = |expected: &'static str, got: &Answer| AnswerMismatch {
324 expected: Some(expected),
325 got: match got {
326 A::Kind(_) => "Kind",
327 A::Confined(_) => "Confined",
328 A::Bytes(_) => "Bytes",
329 A::Entries(_) => "Entries",
330 A::Unavailable { .. } => "Unavailable",
331 },
332 };
333
334 let next = match state {
335 S::ManifestKind => match answer {
337 A::Kind(Some(FileKind::File)) => S::ManifestConfined,
338 A::Kind(None) => S::Done(Box::new(Err(Rejection::ManifestMissing))),
339 A::Kind(Some(_)) => S::Done(Box::new(Err(Rejection::ManifestNotAFile))),
340 A::Unavailable { detail } => S::Done(Box::new(Err(Rejection::Source { detail }))),
341 other => return (S::ManifestKind, Err(mismatch("Kind", &other))),
342 },
343 S::ManifestConfined => match answer {
344 A::Confined(true) => S::ManifestRead,
345 A::Confined(false) => S::Done(Box::new(Err(Rejection::ManifestEscapes))),
346 A::Unavailable { detail } => S::Done(Box::new(Err(Rejection::Source { detail }))),
347 other => return (S::ManifestConfined, Err(mismatch("Confined", &other))),
348 },
349 S::ManifestRead => match answer {
350 A::Bytes(bytes) => match Manifest::parse(&bytes) {
351 Ok((manifest, mut diags)) => {
352 self.manifest = Some(manifest);
353 self.diagnostics.append(&mut diags);
354 S::SkillsKind
355 }
356 Err(rejection) => S::Done(Box::new(Err(Rejection::Manifest(rejection)))),
357 },
358 A::Unavailable { detail } => S::Done(Box::new(Err(Rejection::Source { detail }))),
359 other => return (S::ManifestRead, Err(mismatch("Bytes", &other))),
360 },
361
362 S::SkillsKind => match answer {
364 A::Kind(Some(FileKind::Directory)) => S::SkillsConfined,
365 A::Kind(None) => S::McpKind, A::Kind(Some(_)) => {
367 self.report(
368 Rule::ComponentInvalid,
369 Origin::Skills,
370 "`skills` exists but is not a directory (§6.2)",
371 );
372 S::McpKind
373 }
374 A::Unavailable { detail } => {
375 self.report(Rule::ComponentInvalid, Origin::Skills, detail);
376 S::McpKind
377 }
378 other => return (S::SkillsKind, Err(mismatch("Kind", &other))),
379 },
380 S::SkillsConfined => match answer {
381 A::Confined(true) => S::SkillsList,
382 A::Confined(false) => {
383 self.report(
384 Rule::ComponentInvalid,
385 Origin::Skills,
386 "`skills` resolves outside the plugin root (§4.1)",
387 );
388 S::McpKind
389 }
390 A::Unavailable { detail } => {
391 self.report(Rule::ComponentInvalid, Origin::Skills, detail);
392 S::McpKind
393 }
394 other => return (S::SkillsConfined, Err(mismatch("Confined", &other))),
395 },
396 S::SkillsList => match answer {
397 A::Entries(entries) => {
398 self.skill_queue = entries
401 .into_iter()
402 .filter(|e| e.kind == FileKind::Directory)
403 .map(|e| e.name)
404 .collect();
405 self.next_skill()
406 }
407 A::Unavailable { detail } => {
408 self.report(Rule::ComponentInvalid, Origin::Skills, detail);
409 S::McpKind
410 }
411 other => return (S::SkillsList, Err(mismatch("Entries", &other))),
412 },
413 S::SkillKind { current } => match answer {
414 A::Kind(Some(FileKind::File)) => S::SkillConfined { current },
415 A::Kind(_) => self.next_skill(),
417 A::Unavailable { detail } => {
418 self.skip_skill(¤t, detail);
419 self.next_skill()
420 }
421 other => return (S::SkillKind { current }, Err(mismatch("Kind", &other))),
422 },
423 S::SkillConfined { current } => match answer {
424 A::Confined(true) => S::SkillRead { current },
425 A::Confined(false) => {
426 self.skip_skill(
427 ¤t,
428 "SKILL.md resolves outside the plugin root (§4.1)".to_owned(),
429 );
430 self.next_skill()
431 }
432 A::Unavailable { detail } => {
433 self.skip_skill(¤t, detail);
434 self.next_skill()
435 }
436 other => return (S::SkillConfined { current }, Err(mismatch("Confined", &other))),
437 },
438 S::SkillRead { current } => match answer {
439 A::Bytes(bytes) => {
440 match parse_skill_md(¤t, &bytes) {
441 Ok((meta, body)) => self.skills.push(Skill {
442 directory: current.clone(),
443 path: PackagePath::new("skills").child(¤t),
444 meta,
445 body,
446 }),
447 Err(why) => self.skip_skill(¤t, why.to_string()),
448 }
449 self.next_skill()
450 }
451 A::Unavailable { detail } => {
452 self.skip_skill(¤t, detail);
453 self.next_skill()
454 }
455 other => return (S::SkillRead { current }, Err(mismatch("Bytes", &other))),
456 },
457
458 S::McpKind => match answer {
460 A::Kind(Some(FileKind::File)) => S::McpConfined,
461 A::Kind(None) => self.done(McpOutcome::Absent),
462 A::Kind(Some(_)) => {
463 self.report(
464 Rule::ComponentInvalid,
465 Origin::Mcp,
466 "`mcp.json` exists but is not a regular file (§6.2)",
467 );
468 self.done(McpOutcome::Disabled(McpDisabledReason::NotAFile))
469 }
470 A::Unavailable { detail } => {
471 let reason = McpDisabledReason::Unavailable { detail };
472 self.report(Rule::McpDisabled, Origin::Mcp, reason.to_string());
473 self.done(McpOutcome::Disabled(reason))
474 }
475 other => return (S::McpKind, Err(mismatch("Kind", &other))),
476 },
477 S::McpConfined => match answer {
478 A::Confined(true) => S::McpRead,
479 A::Confined(false) => {
480 self.report(
481 Rule::ComponentInvalid,
482 Origin::Mcp,
483 "`mcp.json` resolves outside the plugin root (§4.1)",
484 );
485 self.done(McpOutcome::Disabled(McpDisabledReason::LocationEscapes))
486 }
487 A::Unavailable { detail } => {
488 let reason = McpDisabledReason::Unavailable { detail };
489 self.report(Rule::McpDisabled, Origin::Mcp, reason.to_string());
490 self.done(McpOutcome::Disabled(reason))
491 }
492 other => return (S::McpConfined, Err(mismatch("Confined", &other))),
493 },
494 S::McpRead => match answer {
495 A::Bytes(bytes) => {
496 let spec =
497 self.manifest.as_ref().expect("manifest parsed before MCP phase").spec;
498 match McpConfig::parse(&bytes, spec) {
499 Ok((config, mut diags)) => {
500 self.diagnostics.append(&mut diags);
501 self.done(McpOutcome::Configured(config))
502 }
503 Err(reason) => {
504 self.report(Rule::McpDisabled, Origin::Mcp, reason.to_string());
505 self.done(McpOutcome::Disabled(reason))
506 }
507 }
508 }
509 A::Unavailable { detail } => {
510 let reason = McpDisabledReason::Unavailable { detail };
511 self.report(Rule::McpDisabled, Origin::Mcp, reason.to_string());
512 self.done(McpOutcome::Disabled(reason))
513 }
514 other => return (S::McpRead, Err(mismatch("Bytes", &other))),
515 },
516
517 S::Done(outcome) => {
518 let got = mismatch("nothing", &answer);
519 return (S::Done(outcome), Err(AnswerMismatch { expected: None, got: got.got }));
520 }
521 S::Poisoned => unreachable!("Loading state invariant broken"),
522 };
523 (next, Ok(()))
524 }
525
526 fn next_skill(&mut self) -> State {
527 match self.skill_queue.pop_front() {
528 Some(current) => State::SkillKind { current },
529 None => State::McpKind,
530 }
531 }
532
533 fn report(&mut self, rule: Rule, origin: Origin, message: impl Into<String>) {
534 self.diagnostics.push(Diagnostic::new(rule, origin, message));
535 }
536
537 fn skip_skill(&mut self, directory: &str, why: String) {
538 self.report(
539 Rule::SkillSkipped,
540 Origin::Skill(directory.to_owned()),
541 format!("skill skipped (§7.1): {why}"),
542 );
543 }
544
545 fn done(&mut self, mcp: McpOutcome) -> State {
546 State::Done(Box::new(Ok(LoadedPlugin {
547 manifest: self.manifest.take().expect("manifest parsed before completion"),
548 skills: std::mem::take(&mut self.skills),
549 mcp,
550 diagnostics: std::mem::take(&mut self.diagnostics),
551 })))
552 }
553}
554
555fn skill_md(directory: &str) -> PackagePath {
556 PackagePath::new("skills").child(directory).child("SKILL.md")
557}
558
559impl fmt::Debug for Loading {
560 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
561 f.debug_struct("Loading")
562 .field("pending", &self.question())
563 .field("skills_loaded", &self.skills.len())
564 .field("diagnostics", &self.diagnostics.len())
565 .finish_non_exhaustive()
566 }
567}