Skip to main content

vagus_plugin/
lib.rs

1//! SDK for building **vagus plugins** — standalone `vagus-<name>` binaries that `vagus` core
2//! dispatches to (the git/`kubectl`/`gh`-extension pattern). See `docs/plugin-contract.md`.
3//!
4//! Using this crate is optional: a plugin in any language can speak the protocol directly. In Rust it
5//! saves you from re-implementing the wire format, the env contract, and the vault-write guard.
6//!
7//! ## Minimal plugin
8//! ```no_run
9//! use vagus_plugin::{Emitter, describe, is_describe};
10//!
11//! fn main() -> std::io::Result<()> {
12//!     let args: Vec<String> = std::env::args().skip(1).collect();
13//!     if is_describe(&args) {
14//!         describe("vagus-hello — example plugin");
15//!         return Ok(());
16//!     }
17//!     let mut out = Emitter::from_env();
18//!     out.progress(1, Some(1), "working");
19//!     out.write_note("30-Resources/hello/note.md", "# hi\n\nfrom a plugin\n")?;
20//!     out.result_ok(serde_json::json!({ "notes": 1 }));
21//!     Ok(())
22//! }
23//! ```
24
25use std::io::Write;
26use std::path::{Path, PathBuf};
27
28use protocol::{Event, LogLevel, NoteAction};
29pub use vagus_plugin_protocol as protocol;
30
31pub use protocol::DESCRIBE_SUBCOMMAND;
32
33/// True when the first arg is the [`DESCRIBE_SUBCOMMAND`].
34pub fn is_describe(args: &[String]) -> bool {
35    args.first().map(String::as_str) == Some(DESCRIBE_SUBCOMMAND)
36}
37
38/// Print a one-line description for `vagus plugins` discovery.
39pub fn describe(text: &str) {
40    println!("{text}");
41}
42
43/// True when core launched us in NDJSON protocol mode (vs. a direct standalone run).
44pub fn protocol_mode() -> bool {
45    std::env::var(protocol::ENV_PROTOCOL).as_deref() == Ok(protocol::PROTOCOL_NDJSON)
46}
47
48/// Path to the `vagus` binary for callbacks (e.g. indexing standalone). Falls back to `vagus` on PATH.
49pub fn vagus_bin() -> PathBuf {
50    std::env::var_os(protocol::ENV_VAGUS_BIN)
51        .map(PathBuf::from)
52        .unwrap_or_else(|| PathBuf::from("vagus"))
53}
54
55/// Resolved vault root. Prefers `$VAGUS_VAULT` (set by core); falls back to the documented `~/brain`
56/// symlink for standalone runs. Returns `None` only if neither is resolvable.
57pub fn vault() -> Option<PathBuf> {
58    if let Some(v) = std::env::var_os(protocol::ENV_VAULT) {
59        return Some(PathBuf::from(v));
60    }
61    dirs::home_dir().map(|h| h.join("brain"))
62}
63
64/// This plugin's own config dir: `~/.config/vagus-<name>/` (XDG, even on macOS — to sit alongside
65/// vagus core, which deliberately uses XDG paths). Core never reads it. Respects `$XDG_CONFIG_HOME`.
66pub fn config_dir(plugin_name: &str) -> Option<PathBuf> {
67    let base = std::env::var_os("XDG_CONFIG_HOME")
68        .map(PathBuf::from)
69        .or_else(|| dirs::home_dir().map(|h| h.join(".config")))?;
70    Some(base.join(format!("vagus-{plugin_name}")))
71}
72
73/// This plugin's own state/cache dir: `~/.local/share/vagus-<name>/` (XDG) — **outside iCloud** (G1).
74/// Respects `$XDG_DATA_HOME`.
75pub fn data_dir(plugin_name: &str) -> Option<PathBuf> {
76    let base = std::env::var_os("XDG_DATA_HOME")
77        .map(PathBuf::from)
78        .or_else(|| dirs::home_dir().map(|h| h.join(".local/share")))?;
79    Some(base.join(format!("vagus-{plugin_name}")))
80}
81
82#[derive(Clone, Copy, PartialEq, Eq)]
83enum Mode {
84    /// Emit NDJSON events on stdout (core is parsing them).
85    Ndjson,
86    /// Standalone run: render human text on stderr; the final result prints to stdout.
87    Human,
88}
89
90/// Emits [`Event`]s to core (NDJSON mode) or renders them for a human (standalone mode).
91///
92/// Construct with [`Emitter::from_env`]. The mode is chosen by [`protocol_mode`]; callers write the
93/// same code either way.
94pub struct Emitter {
95    mode: Mode,
96}
97
98impl Default for Emitter {
99    fn default() -> Self {
100        Self::from_env()
101    }
102}
103
104impl Emitter {
105    pub fn from_env() -> Self {
106        Self {
107            mode: if protocol_mode() {
108                Mode::Ndjson
109            } else {
110                Mode::Human
111            },
112        }
113    }
114
115    fn send(&self, ev: &Event) {
116        match self.mode {
117            Mode::Ndjson => {
118                let mut out = std::io::stdout().lock();
119                let _ = writeln!(out, "{}", ev.to_line());
120                let _ = out.flush();
121            }
122            Mode::Human => self.render_human(ev),
123        }
124    }
125
126    fn render_human(&self, ev: &Event) {
127        match ev {
128            Event::Log { level, msg } => {
129                let tag = match level {
130                    LogLevel::Info => "info",
131                    LogLevel::Warn => "warn",
132                    LogLevel::Error => "error",
133                };
134                eprintln!("{tag}: {msg}");
135            }
136            Event::Progress { done, total, msg } => match total {
137                Some(t) => eprintln!("[{done}/{t}] {msg}"),
138                None => eprintln!("[{done}] {msg}"),
139            },
140            // Notes are silent in human mode (the file write is the visible effect).
141            Event::Note { .. } => {}
142            Event::Result { ok, summary, .. } => {
143                let status = if *ok { "ok" } else { "FAILED" };
144                match summary {
145                    Some(s) => println!("{status}: {s}"),
146                    None => println!("{status}"),
147                }
148            }
149        }
150    }
151
152    pub fn log(&self, level: LogLevel, msg: impl Into<String>) {
153        self.send(&Event::Log {
154            level,
155            msg: msg.into(),
156        });
157    }
158    pub fn info(&self, msg: impl Into<String>) {
159        self.log(LogLevel::Info, msg);
160    }
161    pub fn warn(&self, msg: impl Into<String>) {
162        self.log(LogLevel::Warn, msg);
163    }
164    pub fn error(&self, msg: impl Into<String>) {
165        self.log(LogLevel::Error, msg);
166    }
167
168    pub fn progress(&self, done: u64, total: Option<u64>, msg: impl Into<String>) {
169        self.send(&Event::Progress {
170            done,
171            total,
172            msg: msg.into(),
173        });
174    }
175
176    /// Announce a note at `relpath` (relative to the vault root) so core can index it. Prefer
177    /// [`Emitter::write_note`], which writes the file *and* emits this for you.
178    pub fn note(&self, relpath: impl Into<String>, action: NoteAction) {
179        self.send(&Event::Note {
180            path: relpath.into(),
181            action,
182        });
183    }
184
185    /// Terminal success event with a JSON summary.
186    pub fn result_ok(&self, summary: serde_json::Value) {
187        self.send(&Event::Result {
188            ok: true,
189            summary: Some(summary),
190            data: None,
191            no_index: false,
192        });
193    }
194
195    /// Terminal success event that tells core **not** to index afterwards (e.g. `--dry-run`).
196    pub fn result_ok_no_index(&self, summary: serde_json::Value) {
197        self.send(&Event::Result {
198            ok: true,
199            summary: Some(summary),
200            data: None,
201            no_index: true,
202        });
203    }
204
205    /// Terminal failure event.
206    pub fn result_err(&self, summary: serde_json::Value) {
207        self.send(&Event::Result {
208            ok: false,
209            summary: Some(summary),
210            data: None,
211            no_index: true,
212        });
213    }
214
215    /// Write a Markdown note into the vault and emit the matching [`Event::Note`].
216    ///
217    /// Guards (contract): `relpath` must end in `.md` and must not escape the vault root. Parent
218    /// directories are created. Returns the absolute path written.
219    pub fn write_note(&self, relpath: &str, body: &str) -> std::io::Result<PathBuf> {
220        let abs = self.resolve_note_path(relpath)?;
221        if let Some(parent) = abs.parent() {
222            std::fs::create_dir_all(parent)?;
223        }
224        // A fresh write or an overwrite both index the same way → NoteAction::Write.
225        std::fs::write(&abs, body)?;
226        self.note(relpath, NoteAction::Write);
227        Ok(abs)
228    }
229
230    /// Append to (or create) a Markdown note and emit [`NoteAction::Append`].
231    pub fn append_note(&self, relpath: &str, body: &str) -> std::io::Result<PathBuf> {
232        let abs = self.resolve_note_path(relpath)?;
233        if let Some(parent) = abs.parent() {
234            std::fs::create_dir_all(parent)?;
235        }
236        let mut f = std::fs::OpenOptions::new()
237            .create(true)
238            .append(true)
239            .open(&abs)?;
240        f.write_all(body.as_bytes())?;
241        self.note(relpath, NoteAction::Append);
242        Ok(abs)
243    }
244
245    fn resolve_note_path(&self, relpath: &str) -> std::io::Result<PathBuf> {
246        use std::io::{Error, ErrorKind};
247        let root = vault().ok_or_else(|| {
248            Error::new(
249                ErrorKind::NotFound,
250                "no vault: $VAGUS_VAULT unset and ~/brain not found",
251            )
252        })?;
253        if !relpath.ends_with(".md") {
254            return Err(Error::new(
255                ErrorKind::InvalidInput,
256                format!("plugins may only write .md files into the vault: {relpath:?}"),
257            ));
258        }
259        let rel = Path::new(relpath);
260        // Reject absolute paths and any `..` component — the note must stay under the vault (G1/G16).
261        if rel.is_absolute()
262            || rel
263                .components()
264                .any(|c| matches!(c, std::path::Component::ParentDir))
265        {
266            return Err(Error::new(
267                ErrorKind::InvalidInput,
268                format!("note path must be vault-relative with no `..`: {relpath:?}"),
269            ));
270        }
271        Ok(root.join(rel))
272    }
273}
274
275#[cfg(test)]
276mod tests {
277    use super::*;
278
279    #[test]
280    fn rejects_non_md_and_escapes() {
281        // Force a known vault via env so the test is hermetic.
282        unsafe {
283            std::env::set_var(protocol::ENV_VAULT, "/tmp/vagus-test-vault");
284        }
285        let e = Emitter { mode: Mode::Human };
286        assert!(e.resolve_note_path("notes/x.txt").is_err()); // not .md
287        assert!(e.resolve_note_path("../escape.md").is_err()); // escapes vault
288        assert!(e.resolve_note_path("/abs/x.md").is_err()); // absolute
289        let ok = e.resolve_note_path("30-Resources/slack/a.md").unwrap();
290        assert!(ok.ends_with("30-Resources/slack/a.md"));
291        assert!(ok.starts_with("/tmp/vagus-test-vault"));
292    }
293}