Skip to content

Commit af44bb8

Browse files
committed
feat(obsidian): add CLI wrapper with circuit breaker
1 parent 445581f commit af44bb8

4 files changed

Lines changed: 206 additions & 1 deletion

File tree

Cargo.lock

Lines changed: 14 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ time = "0.3"
3131
strsim = "0.11"
3232
ignore = "0.4"
3333
rmcp = { version = "1.2", features = ["transport-io"] }
34-
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
34+
tokio = { version = "1", features = ["macros", "rt-multi-thread", "process", "time"] }
3535
notify = "7.0"
3636
notify-debouncer-full = "0.4"
3737
llama-cpp-2 = "0.1"

src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ pub mod indexer;
99
pub mod links;
1010
pub mod llm;
1111
pub mod markdown;
12+
pub mod obsidian;
1213
pub mod placement;
1314
pub mod profile;
1415
pub mod search;

src/obsidian.rs

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
use std::process::Command;
2+
use std::time::{Duration, Instant};
3+
4+
use anyhow::{Result, bail};
5+
6+
#[derive(Debug)]
7+
pub enum CircuitState {
8+
Closed,
9+
Degraded,
10+
Open,
11+
}
12+
13+
const COOLDOWN: Duration = Duration::from_secs(60);
14+
const CHECK_TTL: Duration = Duration::from_secs(5);
15+
const CMD_TIMEOUT: Duration = Duration::from_secs(3);
16+
17+
pub struct ObsidianCli {
18+
pub vault_name: String,
19+
pub state: CircuitState,
20+
failures: u32,
21+
last_check: Instant,
22+
last_available: bool,
23+
open_until: Option<Instant>,
24+
}
25+
26+
impl ObsidianCli {
27+
pub fn new(vault_name: String) -> Self {
28+
Self {
29+
vault_name,
30+
state: CircuitState::Closed,
31+
failures: 0,
32+
last_check: Instant::now() - CHECK_TTL, // force first check
33+
last_available: false,
34+
open_until: None,
35+
}
36+
}
37+
38+
/// Record a successful CLI operation. Resets circuit to Closed.
39+
pub fn record_success(&mut self) {
40+
self.failures = 0;
41+
self.state = CircuitState::Closed;
42+
self.open_until = None;
43+
}
44+
45+
/// Record a CLI failure. Transitions Closed→Degraded→Open.
46+
pub fn record_failure(&mut self) {
47+
self.failures += 1;
48+
match self.failures {
49+
1 => self.state = CircuitState::Degraded,
50+
_ => {
51+
self.state = CircuitState::Open;
52+
self.open_until = Some(Instant::now() + COOLDOWN);
53+
}
54+
}
55+
}
56+
57+
/// Check if we should delegate operations to Obsidian CLI.
58+
///
59+
/// Returns false when the circuit is open (and cooldown hasn't expired),
60+
/// or when the Obsidian process isn't running.
61+
pub fn should_delegate(&mut self) -> bool {
62+
// If Open, check cooldown
63+
if matches!(self.state, CircuitState::Open) {
64+
if let Some(until) = self.open_until {
65+
if Instant::now() < until {
66+
return false;
67+
}
68+
// Cooldown expired — transition to Degraded for a retry
69+
self.state = CircuitState::Degraded;
70+
self.failures = 1;
71+
self.open_until = None;
72+
}
73+
}
74+
75+
// Check if Obsidian process is running (cached for CHECK_TTL)
76+
let running = self.check_process();
77+
78+
running && !matches!(self.state, CircuitState::Open)
79+
}
80+
81+
/// Check whether the Obsidian process is running.
82+
/// Result is cached for `CHECK_TTL` to avoid spawning pgrep on every call.
83+
fn check_process(&mut self) -> bool {
84+
if self.last_check.elapsed() < CHECK_TTL {
85+
return self.last_available;
86+
}
87+
88+
let available = Command::new("pgrep")
89+
.arg("-x")
90+
.arg("Obsidian")
91+
.status()
92+
.map(|s| s.success())
93+
.unwrap_or(false);
94+
95+
self.last_check = Instant::now();
96+
self.last_available = available;
97+
available
98+
}
99+
100+
/// Set a property on a vault note via Obsidian CLI.
101+
pub async fn property_set(
102+
&mut self,
103+
file: &str,
104+
name: &str,
105+
value: &str,
106+
) -> Result<String> {
107+
self.run_cli(&["property:set", &format!("name={name}"), &format!("value={value}"), &format!("file={file}")]).await
108+
}
109+
110+
/// Append content to today's daily note via Obsidian CLI.
111+
pub async fn daily_append(&mut self, content: &str) -> Result<String> {
112+
self.run_cli(&["daily:append", &format!("content={content}")]).await
113+
}
114+
115+
/// Execute an Obsidian CLI command with a 3-second timeout.
116+
async fn run_cli(&mut self, args: &[&str]) -> Result<String> {
117+
let vault_arg = format!("vault={}", self.vault_name);
118+
let mut cmd = tokio::process::Command::new("obsidian");
119+
cmd.arg(&vault_arg);
120+
for arg in args {
121+
cmd.arg(arg);
122+
}
123+
124+
let result = tokio::time::timeout(CMD_TIMEOUT, cmd.output()).await;
125+
126+
match result {
127+
Ok(Ok(output)) if output.status.success() => {
128+
self.record_success();
129+
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
130+
}
131+
Ok(Ok(output)) => {
132+
self.record_failure();
133+
let stderr = String::from_utf8_lossy(&output.stderr);
134+
bail!("obsidian CLI failed (exit {}): {stderr}", output.status)
135+
}
136+
Ok(Err(e)) => {
137+
self.record_failure();
138+
bail!("obsidian CLI spawn error: {e}")
139+
}
140+
Err(_) => {
141+
self.record_failure();
142+
bail!("obsidian CLI timed out after {CMD_TIMEOUT:?}")
143+
}
144+
}
145+
}
146+
}
147+
148+
#[cfg(test)]
149+
mod tests {
150+
use super::*;
151+
152+
#[test]
153+
fn test_circuit_starts_closed() {
154+
let cli = ObsidianCli::new("TestVault".into());
155+
assert!(matches!(cli.state, CircuitState::Closed));
156+
}
157+
158+
#[test]
159+
fn test_single_failure_degrades() {
160+
let mut cli = ObsidianCli::new("TestVault".into());
161+
cli.record_failure();
162+
assert!(matches!(cli.state, CircuitState::Degraded));
163+
}
164+
165+
#[test]
166+
fn test_two_failures_opens() {
167+
let mut cli = ObsidianCli::new("TestVault".into());
168+
cli.record_failure();
169+
cli.record_failure();
170+
assert!(matches!(cli.state, CircuitState::Open));
171+
}
172+
173+
#[test]
174+
fn test_success_resets_to_closed() {
175+
let mut cli = ObsidianCli::new("TestVault".into());
176+
cli.record_failure();
177+
assert!(matches!(cli.state, CircuitState::Degraded));
178+
cli.record_success();
179+
assert!(matches!(cli.state, CircuitState::Closed));
180+
}
181+
182+
#[test]
183+
fn test_is_available_when_open_returns_false() {
184+
let mut cli = ObsidianCli::new("TestVault".into());
185+
cli.record_failure();
186+
cli.record_failure();
187+
// Open state — should not be available regardless of process
188+
assert!(!cli.should_delegate());
189+
}
190+
}

0 commit comments

Comments
 (0)