1use 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
33pub fn is_describe(args: &[String]) -> bool {
35 args.first().map(String::as_str) == Some(DESCRIBE_SUBCOMMAND)
36}
37
38pub fn describe(text: &str) {
40 println!("{text}");
41}
42
43pub fn protocol_mode() -> bool {
45 std::env::var(protocol::ENV_PROTOCOL).as_deref() == Ok(protocol::PROTOCOL_NDJSON)
46}
47
48pub 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
55pub 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
64pub 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
73pub 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 Ndjson,
86 Human,
88}
89
90pub 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 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 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 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 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 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 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 std::fs::write(&abs, body)?;
226 self.note(relpath, NoteAction::Write);
227 Ok(abs)
228 }
229
230 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 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 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()); assert!(e.resolve_note_path("../escape.md").is_err()); assert!(e.resolve_note_path("/abs/x.md").is_err()); 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}