Skip to main content

vagus_plugin_protocol/
lib.rs

1//! Wire schema for the **vagus plugin contract** — the language-agnostic protocol a `vagus-<name>`
2//! plugin speaks back to `vagus` core over stdout.
3//!
4//! This crate is intentionally tiny and dependency-light (serde only). It is shared by **both**
5//! `vagus` core (which parses the events) and the `vagus-plugin` SDK (which emits them) so the
6//! on-the-wire shape can never drift between the two. Non-Rust plugins ignore this crate and just
7//! emit the documented JSON; see `docs/plugin-contract.md`.
8//!
9//! ## The contract in one paragraph
10//! Core spawns `vagus-<name>` as a child with the subcommand name stripped from argv, stdin/stderr
11//! inherited, stdout piped, and the [env vars](#constants) set. The plugin streams newline-delimited
12//! JSON [`Event`]s on **stdout** (machine channel) and writes human logs to **stderr**. Any stdout
13//! line that does not parse as a known [`Event`] is echoed verbatim by core (so trivial text-only
14//! plugins still work). On a clean exit, core indexes every [`Event::Note`] path it saw.
15
16use serde::{Deserialize, Serialize};
17
18/// Contract version core advertises via [`ENV_CONTRACT`]. Bumped only on breaking changes; the event
19/// schema is otherwise extended additively (new optional fields, new `#[serde(other)]`-tolerated
20/// variants), so a plugin built against v1 keeps working.
21pub const CONTRACT_VERSION: u32 = 1;
22
23/// Absolute path to the `vagus` binary, so a plugin can call back (e.g. `$VAGUS index`) without
24/// guessing where core lives. Set by core for every plugin invocation.
25pub const ENV_VAGUS_BIN: &str = "VAGUS";
26/// Absolute path to the resolved vault root (the `~/brain` symlink target). Plugins write Markdown
27/// here and **must not** write anything else (guardrail G1/G16).
28pub const ENV_VAULT: &str = "VAGUS_VAULT";
29/// vagus data dir (`~/.local/share/vagus`) — informational; plugins keep their *own* state under
30/// their own XDG dir, never here and never in the vault.
31pub const ENV_DATA_DIR: &str = "VAGUS_DATA_DIR";
32/// vagus config dir — informational.
33pub const ENV_CONFIG_DIR: &str = "VAGUS_CONFIG_DIR";
34/// Core's version string.
35pub const ENV_VERSION: &str = "VAGUS_VERSION";
36/// Set to [`PROTOCOL_NDJSON`] when core is the parent and wants the NDJSON event stream. When unset,
37/// the plugin was run standalone (directly, not via `vagus`) and should print human output and
38/// self-index via `$VAGUS index`.
39pub const ENV_PROTOCOL: &str = "VAGUS_PLUGIN_PROTOCOL";
40/// Decimal [`CONTRACT_VERSION`] core supports, for additive compat checks by the plugin.
41pub const ENV_CONTRACT: &str = "VAGUS_PLUGIN_CONTRACT";
42
43/// The only protocol value currently defined for [`ENV_PROTOCOL`].
44pub const PROTOCOL_NDJSON: &str = "ndjson";
45
46/// Reserved discovery subcommand: `vagus-<name> __describe` prints a one-line summary on stdout for
47/// `vagus plugins`. Used by both core (caller) and the SDK (callee).
48pub const DESCRIBE_SUBCOMMAND: &str = "__describe";
49
50/// Severity for a [`Event::Log`].
51#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
52#[serde(rename_all = "snake_case")]
53pub enum LogLevel {
54    Info,
55    Warn,
56    Error,
57}
58
59/// What happened to a note file, so core knows whether to (re)index or drop it.
60#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
61#[serde(rename_all = "snake_case")]
62pub enum NoteAction {
63    Write,
64    Append,
65    Delete,
66}
67
68/// One newline-delimited JSON record on a plugin's stdout. Externally tagged on `type`.
69///
70/// **Streaming** = emit `Progress`/`Note` as work happens, then a final `Result`. **Batch** = emit
71/// only the final `Result`. Same schema; the plugin chooses.
72#[derive(Serialize, Deserialize, Debug, Clone)]
73#[serde(tag = "type", rename_all = "snake_case")]
74pub enum Event {
75    /// A structured log line core may render uniformly (human-readable logs also go to stderr).
76    Log { level: LogLevel, msg: String },
77    /// Progress for a uniform progress indicator. `total` omitted ⇒ indeterminate.
78    Progress {
79        done: u64,
80        #[serde(default, skip_serializing_if = "Option::is_none")]
81        total: Option<u64>,
82        #[serde(default, skip_serializing_if = "String::is_empty")]
83        msg: String,
84    },
85    /// A vault note the plugin created/changed. `path` is **relative to the vault root**. Core
86    /// collects these and runs one incremental index after the plugin exits cleanly.
87    Note {
88        path: String,
89        #[serde(default = "default_note_action")]
90        action: NoteAction,
91    },
92    /// Terminal event. `summary`/`data` are plugin-defined JSON. `no_index: true` tells core to skip
93    /// the post-run index pass (e.g. a `--dry-run`).
94    Result {
95        ok: bool,
96        #[serde(default, skip_serializing_if = "Option::is_none")]
97        summary: Option<serde_json::Value>,
98        #[serde(default, skip_serializing_if = "Option::is_none")]
99        data: Option<serde_json::Value>,
100        #[serde(default, skip_serializing_if = "is_false")]
101        no_index: bool,
102    },
103}
104
105fn default_note_action() -> NoteAction {
106    NoteAction::Write
107}
108
109fn is_false(b: &bool) -> bool {
110    !*b
111}
112
113impl Event {
114    /// Parse one stdout line as an [`Event`]. Returns `None` when the line is not a known event —
115    /// the caller (core) then echoes that line verbatim. Blank/whitespace lines are `None`.
116    pub fn parse_line(line: &str) -> Option<Event> {
117        let line = line.trim();
118        if line.is_empty() {
119            return None;
120        }
121        serde_json::from_str::<Event>(line).ok()
122    }
123
124    /// Serialize to a single NDJSON line (no trailing newline).
125    pub fn to_line(&self) -> String {
126        // Events are flat and always serialize; unwrap is safe.
127        serde_json::to_string(self).expect("Event serializes")
128    }
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134
135    #[test]
136    fn roundtrips_each_variant() {
137        for ev in [
138            Event::Log {
139                level: LogLevel::Warn,
140                msg: "hi".into(),
141            },
142            Event::Progress {
143                done: 3,
144                total: Some(10),
145                msg: "fetching".into(),
146            },
147            Event::Note {
148                path: "30-Resources/slack/x.md".into(),
149                action: NoteAction::Append,
150            },
151            Event::Result {
152                ok: true,
153                summary: Some(serde_json::json!({"notes": 4})),
154                data: None,
155                no_index: false,
156            },
157        ] {
158            let line = ev.to_line();
159            let back = Event::parse_line(&line).expect("parses");
160            assert_eq!(format!("{ev:?}"), format!("{back:?}"));
161        }
162    }
163
164    #[test]
165    fn progress_total_optional() {
166        let ev = Event::parse_line(r#"{"type":"progress","done":1}"#).unwrap();
167        match ev {
168            Event::Progress { done, total, msg } => {
169                assert_eq!(done, 1);
170                assert!(total.is_none());
171                assert!(msg.is_empty());
172            }
173            _ => panic!("wrong variant"),
174        }
175    }
176
177    #[test]
178    fn note_action_defaults_to_write() {
179        let ev = Event::parse_line(r#"{"type":"note","path":"a.md"}"#).unwrap();
180        assert!(matches!(
181            ev,
182            Event::Note {
183                action: NoteAction::Write,
184                ..
185            }
186        ));
187    }
188
189    #[test]
190    fn non_events_are_none() {
191        assert!(Event::parse_line("just some text").is_none());
192        assert!(Event::parse_line(r#"["a","b"]"#).is_none()); // a plugin's own --json payload
193        assert!(Event::parse_line(r#"{"type":"bogus"}"#).is_none());
194        assert!(Event::parse_line("   ").is_none());
195    }
196}