1use anyhow::{Context, Result};
13use serde::{Deserialize, Serialize};
14use std::process::Command;
15#[cfg(unix)]
16use std::process::Stdio;
17use std::time::Duration;
18
19#[cfg(windows)]
20use std::os::windows::process::CommandExt;
21
22fn percent_encode(s: &str) -> String {
24 s.chars()
25 .map(|c| match c {
26 'A'..='Z' | 'a'..='z' | '0'..='9' | '-' | '_' | '.' | '~' => c.to_string(),
27 ' ' => "+".to_string(),
28 _ => format!("%{:02X}", c as u8),
29 })
30 .collect()
31}
32
33pub const DEFAULT_DAEMON_PORT: u16 = 28428;
35
36#[derive(Debug, Clone)]
38pub struct DaemonClient {
39 port: u16,
41 base_url: String,
43 client: reqwest::Client,
45}
46
47#[derive(Debug, Deserialize, Serialize)]
49pub struct DaemonInfo {
50 pub name: String,
51 pub version: String,
52 pub description: String,
53}
54
55#[derive(Debug, Deserialize, Serialize)]
57pub struct ContextResponse {
58 pub projects_count: usize,
59 pub directories_count: usize,
60 pub last_scan: Option<String>,
61 pub credits_balance: f64,
62}
63
64#[derive(Debug, Deserialize, Serialize)]
66pub struct CreditsResponse {
67 pub balance: f64,
68 pub total_earned: f64,
69 pub total_spent: f64,
70 pub recent_transactions: Vec<Transaction>,
71}
72
73#[derive(Debug, Deserialize, Serialize)]
74pub struct Transaction {
75 pub timestamp: String,
76 pub amount: f64,
77 pub description: String,
78}
79
80#[derive(Debug, Deserialize, Serialize, Clone)]
82pub struct ProjectInfo {
83 pub path: String,
84 pub name: String,
85 pub project_type: String,
86 pub key_files: Vec<String>,
87 pub essence: String,
88}
89
90#[derive(Debug, Serialize)]
92pub struct ToolCallRequest {
93 pub name: String,
94 pub arguments: serde_json::Value,
95}
96
97#[derive(Debug)]
99pub enum DaemonStatus {
100 Running(DaemonInfo),
102 NotRunning,
104 Starting,
106 Error(String),
108}
109
110impl DaemonClient {
111 pub fn new(port: u16) -> Self {
113 let token = crate::daemon::load_token();
114
115 let mut builder = reqwest::Client::builder()
116 .timeout(Duration::from_secs(5));
117
118 if let Some(ref tok) = token {
119 let mut headers = reqwest::header::HeaderMap::new();
120 if let Ok(val) = reqwest::header::HeaderValue::from_str(&format!("Bearer {}", tok)) {
121 headers.insert(reqwest::header::AUTHORIZATION, val);
122 }
123 builder = builder.default_headers(headers);
124 }
125
126 let client = builder.build().unwrap_or_default();
127
128 Self {
129 port,
130 base_url: format!("http://127.0.0.1:{}", port),
131 client,
132 }
133 }
134
135 pub fn default_port() -> Self {
137 Self::new(DEFAULT_DAEMON_PORT)
138 }
139
140 pub async fn check_status(&self) -> DaemonStatus {
142 match self.health_check().await {
143 Ok(true) => {
144 match self.get_info().await {
146 Ok(info) => DaemonStatus::Running(info),
147 Err(_) => DaemonStatus::Running(DaemonInfo {
148 name: "smart-tree-daemon".to_string(),
149 version: "unknown".to_string(),
150 description: "Running".to_string(),
151 }),
152 }
153 }
154 Ok(false) => DaemonStatus::NotRunning,
155 Err(e) => {
156 let err_str = e.to_string().to_lowercase();
158 if err_str.contains("connection refused")
159 || err_str.contains("tcp connect error")
160 || err_str.contains("connect error")
161 || err_str.contains("error sending request")
162 {
163 DaemonStatus::NotRunning
164 } else {
165 DaemonStatus::Error(e.to_string())
166 }
167 }
168 }
169 }
170
171 pub async fn health_check(&self) -> Result<bool> {
173 let url = format!("{}/health", self.base_url);
174 match self.client.get(&url).send().await {
175 Ok(resp) => Ok(resp.status().is_success()),
176 Err(e) => Err(anyhow::anyhow!("Health check failed: {}", e)),
177 }
178 }
179
180 pub async fn get_info(&self) -> Result<DaemonInfo> {
182 let url = format!("{}/info", self.base_url);
183 let resp = self
184 .client
185 .get(&url)
186 .send()
187 .await
188 .context("Failed to connect to daemon")?;
189
190 resp.json::<DaemonInfo>()
191 .await
192 .context("Failed to parse daemon info")
193 }
194
195 pub async fn get_context(&self) -> Result<ContextResponse> {
197 let url = format!("{}/context", self.base_url);
198 let resp = self
199 .client
200 .get(&url)
201 .send()
202 .await
203 .context("Failed to connect to daemon")?;
204
205 resp.json::<ContextResponse>()
206 .await
207 .context("Failed to parse context response")
208 }
209
210 pub async fn get_projects(&self) -> Result<Vec<ProjectInfo>> {
212 let url = format!("{}/context/projects", self.base_url);
213 let resp = self
214 .client
215 .get(&url)
216 .send()
217 .await
218 .context("Failed to connect to daemon")?;
219
220 resp.json::<Vec<ProjectInfo>>()
221 .await
222 .context("Failed to parse projects response")
223 }
224
225 pub async fn query_context(&self, query: &str) -> Result<serde_json::Value> {
227 let url = format!("{}/context/query", self.base_url);
228 let resp = self
229 .client
230 .post(&url)
231 .json(&serde_json::json!({ "query": query }))
232 .send()
233 .await
234 .context("Failed to connect to daemon")?;
235
236 resp.json::<serde_json::Value>()
237 .await
238 .context("Failed to parse query response")
239 }
240
241 pub async fn list_files(
243 &self,
244 path: Option<&str>,
245 pattern: Option<&str>,
246 depth: Option<usize>,
247 ) -> Result<Vec<String>> {
248 let mut url = format!("{}/context/files?", self.base_url);
249
250 if let Some(p) = path {
251 url.push_str(&format!("path={}&", percent_encode(p)));
252 }
253 if let Some(pat) = pattern {
254 url.push_str(&format!("pattern={}&", percent_encode(pat)));
255 }
256 if let Some(d) = depth {
257 url.push_str(&format!("depth={}", d));
258 }
259
260 let resp = self
261 .client
262 .get(&url)
263 .send()
264 .await
265 .context("Failed to connect to daemon")?;
266
267 resp.json::<Vec<String>>()
268 .await
269 .context("Failed to parse files response")
270 }
271
272 pub async fn get_credits(&self) -> Result<CreditsResponse> {
274 let url = format!("{}/credits", self.base_url);
275 let resp = self
276 .client
277 .get(&url)
278 .send()
279 .await
280 .context("Failed to connect to daemon")?;
281
282 resp.json::<CreditsResponse>()
283 .await
284 .context("Failed to parse credits response")
285 }
286
287 pub async fn record_savings(
289 &self,
290 tokens_saved: u64,
291 description: &str,
292 ) -> Result<CreditsResponse> {
293 let url = format!("{}/credits/record", self.base_url);
294 let resp = self
295 .client
296 .post(&url)
297 .json(&serde_json::json!({
298 "tokens_saved": tokens_saved,
299 "description": description
300 }))
301 .send()
302 .await
303 .context("Failed to connect to daemon")?;
304
305 resp.json::<CreditsResponse>()
306 .await
307 .context("Failed to parse credits response")
308 }
309
310 pub async fn call_tool(
312 &self,
313 name: &str,
314 arguments: serde_json::Value,
315 ) -> Result<serde_json::Value> {
316 let url = format!("{}/tools/call", self.base_url);
317 let req = ToolCallRequest {
318 name: name.to_string(),
319 arguments,
320 };
321
322 let resp = self
323 .client
324 .post(&url)
325 .json(&req)
326 .send()
327 .await
328 .context("Failed to connect to daemon")?;
329
330 resp.json::<serde_json::Value>()
331 .await
332 .context("Failed to parse tool response")
333 }
334
335 pub async fn list_tools(&self) -> Result<Vec<serde_json::Value>> {
337 let url = format!("{}/tools", self.base_url);
338 let resp = self
339 .client
340 .get(&url)
341 .send()
342 .await
343 .context("Failed to connect to daemon")?;
344
345 resp.json::<Vec<serde_json::Value>>()
346 .await
347 .context("Failed to parse tools list")
348 }
349
350 pub async fn cli_scan(
355 &self,
356 request: crate::daemon_cli::CliScanRequest,
357 ) -> Result<crate::daemon_cli::CliScanResponse> {
358 let url = format!("{}/cli/scan", self.base_url);
359
360 let mut builder = reqwest::Client::builder()
362 .timeout(std::time::Duration::from_secs(120));
363
364 if let Some(tok) = crate::daemon::load_token() {
365 let mut headers = reqwest::header::HeaderMap::new();
366 if let Ok(val) = reqwest::header::HeaderValue::from_str(&format!("Bearer {}", tok)) {
367 headers.insert(reqwest::header::AUTHORIZATION, val);
368 }
369 builder = builder.default_headers(headers);
370 }
371
372 let client = builder.build().unwrap_or_default();
373
374 let resp = client
375 .post(&url)
376 .json(&request)
377 .send()
378 .await
379 .context("Failed to connect to daemon for CLI scan")?;
380
381 if !resp.status().is_success() {
382 let status = resp.status();
383 let error_body = resp.text().await.unwrap_or_default();
384 return Err(anyhow::anyhow!(
385 "CLI scan failed with status {}: {}",
386 status,
387 error_body
388 ));
389 }
390
391 resp.json::<crate::daemon_cli::CliScanResponse>()
392 .await
393 .context("Failed to parse CLI scan response")
394 }
395
396 pub async fn start_daemon(&self) -> Result<bool> {
400 if matches!(self.check_status().await, DaemonStatus::Running(_)) {
402 return Ok(false);
403 }
404
405 let exe_path = std::env::current_exe().context("Failed to get current executable path")?;
407
408 #[cfg(unix)]
411 {
412 Command::new(&exe_path)
413 .args(["--daemon", "--daemon-port", &self.port.to_string()])
414 .stdin(Stdio::null())
415 .stdout(Stdio::null())
416 .stderr(Stdio::null())
417 .spawn()
418 .context("Failed to start daemon process")?;
419 }
420
421 #[cfg(windows)]
422 {
423 Command::new(&exe_path)
424 .args(["--daemon", "--daemon-port", &self.port.to_string()])
425 .creation_flags(0x00000008) .spawn()
427 .context("Failed to start daemon process")?;
428 }
429
430 tokio::time::sleep(Duration::from_millis(500)).await;
432
433 for _ in 0..10 {
435 if self.health_check().await.unwrap_or(false) {
436 return Ok(true);
437 }
438 tokio::time::sleep(Duration::from_millis(500)).await;
439 }
440
441 Err(anyhow::anyhow!(
442 "Daemon started but failed to become healthy within 5 seconds"
443 ))
444 }
445
446 pub async fn stop_daemon(&self) -> Result<bool> {
450 if !matches!(self.check_status().await, DaemonStatus::Running(_)) {
452 return Ok(false);
453 }
454
455 let url = format!("{}/shutdown", self.base_url);
457 match self.client.post(&url).send().await {
458 Ok(_) => {
459 tokio::time::sleep(Duration::from_millis(500)).await;
461 Ok(true)
462 }
463 Err(_) => {
464 #[cfg(unix)]
466 {
467 let output = Command::new("lsof")
469 .args(["-ti", &format!(":{}", self.port)])
470 .output();
471
472 if let Ok(output) = output {
473 if let Ok(pid_str) = String::from_utf8(output.stdout) {
474 for pid in pid_str.lines() {
475 if let Ok(pid) = pid.trim().parse::<i32>() {
476 let _ = Command::new("kill").arg(pid.to_string()).output();
477 }
478 }
479 return Ok(true);
480 }
481 }
482 }
483
484 Err(anyhow::anyhow!("Failed to stop daemon"))
485 }
486 }
487 }
488
489 pub async fn ensure_running(&self) -> Result<DaemonInfo> {
494 match self.check_status().await {
495 DaemonStatus::Running(info) => Ok(info),
496 DaemonStatus::NotRunning => {
497 eprintln!("🌳 Starting Smart Tree daemon on port {}...", self.port);
498 self.start_daemon().await?;
499
500 let mut delay = Duration::from_millis(100);
502 for attempt in 1..=5 {
503 match self.get_info().await {
504 Ok(info) => {
505 eprintln!("✅ Daemon started successfully!");
506 return Ok(info);
507 }
508 Err(_e) if attempt < 5 => {
509 eprintln!(
510 "⏳ Waiting for daemon to become ready... (attempt {}/5)",
511 attempt
512 );
513 tokio::time::sleep(delay).await;
514 delay *= 2; }
516 Err(e) => {
517 return Err(anyhow::anyhow!(
518 "Daemon started but failed to respond after 5 attempts: {}",
519 e
520 ));
521 }
522 }
523 }
524 unreachable!("Loop should always return")
525 }
526 DaemonStatus::Starting => {
527 eprintln!("⏳ Daemon is starting, waiting...");
528 let mut delay = Duration::from_millis(500);
530 for attempt in 1..=6 {
531 tokio::time::sleep(delay).await;
532 match self.check_status().await {
533 DaemonStatus::Running(info) => {
534 eprintln!("✅ Daemon is now running!");
535 return Ok(info);
536 }
537 DaemonStatus::Starting if attempt < 6 => {
538 eprintln!("⏳ Still starting... (attempt {}/6)", attempt);
539 delay *= 2; }
541 DaemonStatus::NotRunning => {
542 return Err(anyhow::anyhow!(
543 "Daemon stopped during startup; it did not remain in Starting state"
544 ));
545 }
546 DaemonStatus::Error(e) => {
547 return Err(anyhow::anyhow!("Daemon startup failed: {}", e));
548 }
549 DaemonStatus::Starting => {
550 return Err(anyhow::anyhow!("Daemon failed to start within timeout"));
552 }
553 }
554 }
555 unreachable!("Loop should always return")
556 }
557 DaemonStatus::Error(e) => Err(anyhow::anyhow!(
558 "Daemon error: {}. Try running 'st --daemon-stop' and then 'st --daemon-start' to restart.",
559 e
560 )),
561 }
562 }
563}
564
565pub fn print_daemon_status(status: &DaemonStatus) {
567 match status {
568 DaemonStatus::Running(info) => {
569 println!("╔═══════════════════════════════════════════════════════════╗");
570 println!("║ 🌳 SMART TREE DAEMON STATUS: RUNNING 🌳 ║");
571 println!("╠═══════════════════════════════════════════════════════════╣");
572 println!("║ Name: {:<45} ║", info.name);
573 println!("║ Version: {:<45} ║", info.version);
574 println!(
575 "║ Description: {:<45} ║",
576 truncate_str(&info.description, 45)
577 );
578 println!("╚═══════════════════════════════════════════════════════════╝");
579 }
580 DaemonStatus::NotRunning => {
581 println!("╔═══════════════════════════════════════════════════════════╗");
582 println!("║ 🌳 SMART TREE DAEMON STATUS: STOPPED 🛑 ║");
583 println!("╠═══════════════════════════════════════════════════════════╣");
584 println!("║ The daemon is not running. ║");
585 println!("║ Start with: st --daemon-start ║");
586 println!("╚═══════════════════════════════════════════════════════════╝");
587 }
588 DaemonStatus::Starting => {
589 println!("╔═══════════════════════════════════════════════════════════╗");
590 println!("║ 🌳 SMART TREE DAEMON STATUS: STARTING ⏳ ║");
591 println!("╠═══════════════════════════════════════════════════════════╣");
592 println!("║ The daemon is starting up... ║");
593 println!("╚═══════════════════════════════════════════════════════════╝");
594 }
595 DaemonStatus::Error(e) => {
596 println!("╔═══════════════════════════════════════════════════════════╗");
597 println!("║ 🌳 SMART TREE DAEMON STATUS: ERROR ❌ ║");
598 println!("╠═══════════════════════════════════════════════════════════╣");
599 println!("║ Error: {:<50} ║", truncate_str(e, 50));
600 println!("╚═══════════════════════════════════════════════════════════╝");
601 }
602 }
603}
604
605pub fn print_context_summary(ctx: &ContextResponse) {
607 println!("╔═══════════════════════════════════════════════════════════╗");
608 println!("║ 📊 SYSTEM CONTEXT SUMMARY 📊 ║");
609 println!("╠═══════════════════════════════════════════════════════════╣");
610 println!("║ Projects detected: {:<35} ║", ctx.projects_count);
611 println!("║ Directories tracked: {:<35} ║", ctx.directories_count);
612 println!(
613 "║ Last scan: {:<35} ║",
614 ctx.last_scan.as_deref().unwrap_or("Never")
615 );
616 println!("║ Foken balance: {:<35.2} ║", ctx.credits_balance);
617 println!("╚═══════════════════════════════════════════════════════════╝");
618}
619
620pub fn print_credits(credits: &CreditsResponse) {
622 println!("╔═══════════════════════════════════════════════════════════╗");
623 println!("║ 💰 FOKEN CREDITS SUMMARY 💰 ║");
624 println!("╠═══════════════════════════════════════════════════════════╣");
625 println!("║ Current Balance: {:<38.2} ║", credits.balance);
626 println!("║ Total Earned: {:<38.2} ║", credits.total_earned);
627 println!("║ Total Spent: {:<38.2} ║", credits.total_spent);
628 if !credits.recent_transactions.is_empty() {
629 println!("╠═══════════════════════════════════════════════════════════╣");
630 println!("║ Recent Transactions: ║");
631 for tx in credits.recent_transactions.iter().take(5) {
632 println!(
633 "║ +{:>8.0} - {:<43} ║",
634 tx.amount,
635 truncate_str(&tx.description, 43)
636 );
637 }
638 }
639 println!("╚═══════════════════════════════════════════════════════════╝");
640}
641
642pub fn print_projects(projects: &[ProjectInfo]) {
644 println!("╔═══════════════════════════════════════════════════════════╗");
645 println!("║ 📁 DETECTED PROJECTS 📁 ║");
646 println!("╠═══════════════════════════════════════════════════════════╣");
647 if projects.is_empty() {
648 println!("║ No projects detected yet. ║");
649 println!("║ Add directories to watch with: st --daemon-watch <path> ║");
650 } else {
651 for p in projects.iter().take(10) {
652 println!("║ 📦 {:<53} ║", truncate_str(&p.name, 53));
653 println!("║ Type: {:<47} ║", p.project_type);
654 println!("║ Path: {:<47} ║", truncate_str(&p.path, 47));
655 if !p.key_files.is_empty() {
656 println!(
657 "║ Files: {:<46} ║",
658 truncate_str(&p.key_files.join(", "), 46)
659 );
660 }
661 }
662 if projects.len() > 10 {
663 println!(
664 "║ ... and {} more projects ║",
665 projects.len() - 10
666 );
667 }
668 }
669 println!("╚═══════════════════════════════════════════════════════════╝");
670}
671
672fn truncate_str(s: &str, max_len: usize) -> String {
674 if s.len() <= max_len {
675 s.to_string()
676 } else {
677 format!("{}...", &s[..max_len - 3])
678 }
679}
680
681#[cfg(test)]
682mod tests {
683 use super::*;
684
685 #[test]
686 fn test_client_creation() {
687 let client = DaemonClient::new(28428);
688 assert_eq!(client.port, 28428);
689 assert_eq!(client.base_url, "http://127.0.0.1:28428");
690 }
691
692 #[test]
693 fn test_default_port() {
694 let client = DaemonClient::default_port();
695 assert_eq!(client.port, DEFAULT_DAEMON_PORT);
696 }
697
698 #[tokio::test]
699 async fn test_status_when_not_running() {
700 let client = DaemonClient::new(59999);
702 let status = client.check_status().await;
703 assert!(matches!(
704 status,
705 DaemonStatus::NotRunning | DaemonStatus::Error(_)
706 ));
707 }
708}