use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::process::Command;
#[cfg(unix)]
use std::process::Stdio;
use std::time::Duration;
#[cfg(windows)]
use std::os::windows::process::CommandExt;
fn percent_encode(s: &str) -> String {
s.chars()
.map(|c| match c {
'A'..='Z' | 'a'..='z' | '0'..='9' | '-' | '_' | '.' | '~' => c.to_string(),
' ' => "+".to_string(),
_ => format!("%{:02X}", c as u8),
})
.collect()
}
pub const DEFAULT_DAEMON_PORT: u16 = 28428;
#[derive(Debug, Clone)]
pub struct DaemonClient {
port: u16,
base_url: String,
client: reqwest::Client,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct DaemonInfo {
pub name: String,
pub version: String,
pub description: String,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct ContextResponse {
pub projects_count: usize,
pub directories_count: usize,
pub last_scan: Option<String>,
pub credits_balance: f64,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct CreditsResponse {
pub balance: f64,
pub total_earned: f64,
pub total_spent: f64,
pub recent_transactions: Vec<Transaction>,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct Transaction {
pub timestamp: String,
pub amount: f64,
pub description: String,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct ProjectInfo {
pub path: String,
pub name: String,
pub project_type: String,
pub key_files: Vec<String>,
pub essence: String,
}
#[derive(Debug, Serialize)]
pub struct ToolCallRequest {
pub name: String,
pub arguments: serde_json::Value,
}
#[derive(Debug)]
pub enum DaemonStatus {
Running(DaemonInfo),
NotRunning,
Starting,
Error(String),
}
impl DaemonClient {
pub fn new(port: u16) -> Self {
let token = crate::daemon::load_token();
let mut builder = reqwest::Client::builder()
.timeout(Duration::from_secs(5));
if let Some(ref tok) = token {
let mut headers = reqwest::header::HeaderMap::new();
if let Ok(val) = reqwest::header::HeaderValue::from_str(&format!("Bearer {}", tok)) {
headers.insert(reqwest::header::AUTHORIZATION, val);
}
builder = builder.default_headers(headers);
}
let client = builder.build().unwrap_or_default();
Self {
port,
base_url: format!("http://127.0.0.1:{}", port),
client,
}
}
pub fn default_port() -> Self {
Self::new(DEFAULT_DAEMON_PORT)
}
pub async fn check_status(&self) -> DaemonStatus {
match self.health_check().await {
Ok(true) => {
match self.get_info().await {
Ok(info) => DaemonStatus::Running(info),
Err(_) => DaemonStatus::Running(DaemonInfo {
name: "smart-tree-daemon".to_string(),
version: "unknown".to_string(),
description: "Running".to_string(),
}),
}
}
Ok(false) => DaemonStatus::NotRunning,
Err(e) => {
let err_str = e.to_string().to_lowercase();
if err_str.contains("connection refused")
|| err_str.contains("tcp connect error")
|| err_str.contains("connect error")
|| err_str.contains("error sending request")
{
DaemonStatus::NotRunning
} else {
DaemonStatus::Error(e.to_string())
}
}
}
}
pub async fn health_check(&self) -> Result<bool> {
let url = format!("{}/health", self.base_url);
match self.client.get(&url).send().await {
Ok(resp) => Ok(resp.status().is_success()),
Err(e) => Err(anyhow::anyhow!("Health check failed: {}", e)),
}
}
pub async fn get_info(&self) -> Result<DaemonInfo> {
let url = format!("{}/info", self.base_url);
let resp = self
.client
.get(&url)
.send()
.await
.context("Failed to connect to daemon")?;
resp.json::<DaemonInfo>()
.await
.context("Failed to parse daemon info")
}
pub async fn get_context(&self) -> Result<ContextResponse> {
let url = format!("{}/context", self.base_url);
let resp = self
.client
.get(&url)
.send()
.await
.context("Failed to connect to daemon")?;
resp.json::<ContextResponse>()
.await
.context("Failed to parse context response")
}
pub async fn get_projects(&self) -> Result<Vec<ProjectInfo>> {
let url = format!("{}/context/projects", self.base_url);
let resp = self
.client
.get(&url)
.send()
.await
.context("Failed to connect to daemon")?;
resp.json::<Vec<ProjectInfo>>()
.await
.context("Failed to parse projects response")
}
pub async fn query_context(&self, query: &str) -> Result<serde_json::Value> {
let url = format!("{}/context/query", self.base_url);
let resp = self
.client
.post(&url)
.json(&serde_json::json!({ "query": query }))
.send()
.await
.context("Failed to connect to daemon")?;
resp.json::<serde_json::Value>()
.await
.context("Failed to parse query response")
}
pub async fn list_files(
&self,
path: Option<&str>,
pattern: Option<&str>,
depth: Option<usize>,
) -> Result<Vec<String>> {
let mut url = format!("{}/context/files?", self.base_url);
if let Some(p) = path {
url.push_str(&format!("path={}&", percent_encode(p)));
}
if let Some(pat) = pattern {
url.push_str(&format!("pattern={}&", percent_encode(pat)));
}
if let Some(d) = depth {
url.push_str(&format!("depth={}", d));
}
let resp = self
.client
.get(&url)
.send()
.await
.context("Failed to connect to daemon")?;
resp.json::<Vec<String>>()
.await
.context("Failed to parse files response")
}
pub async fn get_credits(&self) -> Result<CreditsResponse> {
let url = format!("{}/credits", self.base_url);
let resp = self
.client
.get(&url)
.send()
.await
.context("Failed to connect to daemon")?;
resp.json::<CreditsResponse>()
.await
.context("Failed to parse credits response")
}
pub async fn record_savings(
&self,
tokens_saved: u64,
description: &str,
) -> Result<CreditsResponse> {
let url = format!("{}/credits/record", self.base_url);
let resp = self
.client
.post(&url)
.json(&serde_json::json!({
"tokens_saved": tokens_saved,
"description": description
}))
.send()
.await
.context("Failed to connect to daemon")?;
resp.json::<CreditsResponse>()
.await
.context("Failed to parse credits response")
}
pub async fn call_tool(
&self,
name: &str,
arguments: serde_json::Value,
) -> Result<serde_json::Value> {
let url = format!("{}/tools/call", self.base_url);
let req = ToolCallRequest {
name: name.to_string(),
arguments,
};
let resp = self
.client
.post(&url)
.json(&req)
.send()
.await
.context("Failed to connect to daemon")?;
resp.json::<serde_json::Value>()
.await
.context("Failed to parse tool response")
}
pub async fn list_tools(&self) -> Result<Vec<serde_json::Value>> {
let url = format!("{}/tools", self.base_url);
let resp = self
.client
.get(&url)
.send()
.await
.context("Failed to connect to daemon")?;
resp.json::<Vec<serde_json::Value>>()
.await
.context("Failed to parse tools list")
}
pub async fn cli_scan(
&self,
request: crate::daemon_cli::CliScanRequest,
) -> Result<crate::daemon_cli::CliScanResponse> {
let url = format!("{}/cli/scan", self.base_url);
let mut builder = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(120));
if let Some(tok) = crate::daemon::load_token() {
let mut headers = reqwest::header::HeaderMap::new();
if let Ok(val) = reqwest::header::HeaderValue::from_str(&format!("Bearer {}", tok)) {
headers.insert(reqwest::header::AUTHORIZATION, val);
}
builder = builder.default_headers(headers);
}
let client = builder.build().unwrap_or_default();
let resp = client
.post(&url)
.json(&request)
.send()
.await
.context("Failed to connect to daemon for CLI scan")?;
if !resp.status().is_success() {
let status = resp.status();
let error_body = resp.text().await.unwrap_or_default();
return Err(anyhow::anyhow!(
"CLI scan failed with status {}: {}",
status,
error_body
));
}
resp.json::<crate::daemon_cli::CliScanResponse>()
.await
.context("Failed to parse CLI scan response")
}
pub async fn start_daemon(&self) -> Result<bool> {
if matches!(self.check_status().await, DaemonStatus::Running(_)) {
return Ok(false);
}
let exe_path = std::env::current_exe().context("Failed to get current executable path")?;
#[cfg(unix)]
{
Command::new(&exe_path)
.args(["--daemon", "--daemon-port", &self.port.to_string()])
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.context("Failed to start daemon process")?;
}
#[cfg(windows)]
{
Command::new(&exe_path)
.args(["--daemon", "--daemon-port", &self.port.to_string()])
.creation_flags(0x00000008) .spawn()
.context("Failed to start daemon process")?;
}
tokio::time::sleep(Duration::from_millis(500)).await;
for _ in 0..10 {
if self.health_check().await.unwrap_or(false) {
return Ok(true);
}
tokio::time::sleep(Duration::from_millis(500)).await;
}
Err(anyhow::anyhow!(
"Daemon started but failed to become healthy within 5 seconds"
))
}
pub async fn stop_daemon(&self) -> Result<bool> {
if !matches!(self.check_status().await, DaemonStatus::Running(_)) {
return Ok(false);
}
let url = format!("{}/shutdown", self.base_url);
match self.client.post(&url).send().await {
Ok(_) => {
tokio::time::sleep(Duration::from_millis(500)).await;
Ok(true)
}
Err(_) => {
#[cfg(unix)]
{
let output = Command::new("lsof")
.args(["-ti", &format!(":{}", self.port)])
.output();
if let Ok(output) = output {
if let Ok(pid_str) = String::from_utf8(output.stdout) {
for pid in pid_str.lines() {
if let Ok(pid) = pid.trim().parse::<i32>() {
let _ = Command::new("kill").arg(pid.to_string()).output();
}
}
return Ok(true);
}
}
}
Err(anyhow::anyhow!("Failed to stop daemon"))
}
}
}
pub async fn ensure_running(&self) -> Result<DaemonInfo> {
match self.check_status().await {
DaemonStatus::Running(info) => Ok(info),
DaemonStatus::NotRunning => {
eprintln!("🌳 Starting Smart Tree daemon on port {}...", self.port);
self.start_daemon().await?;
let mut delay = Duration::from_millis(100);
for attempt in 1..=5 {
match self.get_info().await {
Ok(info) => {
eprintln!("✅ Daemon started successfully!");
return Ok(info);
}
Err(_e) if attempt < 5 => {
eprintln!(
"⏳ Waiting for daemon to become ready... (attempt {}/5)",
attempt
);
tokio::time::sleep(delay).await;
delay *= 2; }
Err(e) => {
return Err(anyhow::anyhow!(
"Daemon started but failed to respond after 5 attempts: {}",
e
));
}
}
}
unreachable!("Loop should always return")
}
DaemonStatus::Starting => {
eprintln!("⏳ Daemon is starting, waiting...");
let mut delay = Duration::from_millis(500);
for attempt in 1..=6 {
tokio::time::sleep(delay).await;
match self.check_status().await {
DaemonStatus::Running(info) => {
eprintln!("✅ Daemon is now running!");
return Ok(info);
}
DaemonStatus::Starting if attempt < 6 => {
eprintln!("⏳ Still starting... (attempt {}/6)", attempt);
delay *= 2; }
DaemonStatus::NotRunning => {
return Err(anyhow::anyhow!(
"Daemon stopped during startup; it did not remain in Starting state"
));
}
DaemonStatus::Error(e) => {
return Err(anyhow::anyhow!("Daemon startup failed: {}", e));
}
DaemonStatus::Starting => {
return Err(anyhow::anyhow!("Daemon failed to start within timeout"));
}
}
}
unreachable!("Loop should always return")
}
DaemonStatus::Error(e) => Err(anyhow::anyhow!(
"Daemon error: {}. Try running 'st --daemon-stop' and then 'st --daemon-start' to restart.",
e
)),
}
}
}
pub fn print_daemon_status(status: &DaemonStatus) {
match status {
DaemonStatus::Running(info) => {
println!("╔═══════════════════════════════════════════════════════════╗");
println!("║ 🌳 SMART TREE DAEMON STATUS: RUNNING 🌳 ║");
println!("╠═══════════════════════════════════════════════════════════╣");
println!("║ Name: {:<45} ║", info.name);
println!("║ Version: {:<45} ║", info.version);
println!(
"║ Description: {:<45} ║",
truncate_str(&info.description, 45)
);
println!("╚═══════════════════════════════════════════════════════════╝");
}
DaemonStatus::NotRunning => {
println!("╔═══════════════════════════════════════════════════════════╗");
println!("║ 🌳 SMART TREE DAEMON STATUS: STOPPED 🛑 ║");
println!("╠═══════════════════════════════════════════════════════════╣");
println!("║ The daemon is not running. ║");
println!("║ Start with: st --daemon-start ║");
println!("╚═══════════════════════════════════════════════════════════╝");
}
DaemonStatus::Starting => {
println!("╔═══════════════════════════════════════════════════════════╗");
println!("║ 🌳 SMART TREE DAEMON STATUS: STARTING ⏳ ║");
println!("╠═══════════════════════════════════════════════════════════╣");
println!("║ The daemon is starting up... ║");
println!("╚═══════════════════════════════════════════════════════════╝");
}
DaemonStatus::Error(e) => {
println!("╔═══════════════════════════════════════════════════════════╗");
println!("║ 🌳 SMART TREE DAEMON STATUS: ERROR ❌ ║");
println!("╠═══════════════════════════════════════════════════════════╣");
println!("║ Error: {:<50} ║", truncate_str(e, 50));
println!("╚═══════════════════════════════════════════════════════════╝");
}
}
}
pub fn print_context_summary(ctx: &ContextResponse) {
println!("╔═══════════════════════════════════════════════════════════╗");
println!("║ 📊 SYSTEM CONTEXT SUMMARY 📊 ║");
println!("╠═══════════════════════════════════════════════════════════╣");
println!("║ Projects detected: {:<35} ║", ctx.projects_count);
println!("║ Directories tracked: {:<35} ║", ctx.directories_count);
println!(
"║ Last scan: {:<35} ║",
ctx.last_scan.as_deref().unwrap_or("Never")
);
println!("║ Foken balance: {:<35.2} ║", ctx.credits_balance);
println!("╚═══════════════════════════════════════════════════════════╝");
}
pub fn print_credits(credits: &CreditsResponse) {
println!("╔═══════════════════════════════════════════════════════════╗");
println!("║ 💰 FOKEN CREDITS SUMMARY 💰 ║");
println!("╠═══════════════════════════════════════════════════════════╣");
println!("║ Current Balance: {:<38.2} ║", credits.balance);
println!("║ Total Earned: {:<38.2} ║", credits.total_earned);
println!("║ Total Spent: {:<38.2} ║", credits.total_spent);
if !credits.recent_transactions.is_empty() {
println!("╠═══════════════════════════════════════════════════════════╣");
println!("║ Recent Transactions: ║");
for tx in credits.recent_transactions.iter().take(5) {
println!(
"║ +{:>8.0} - {:<43} ║",
tx.amount,
truncate_str(&tx.description, 43)
);
}
}
println!("╚═══════════════════════════════════════════════════════════╝");
}
pub fn print_projects(projects: &[ProjectInfo]) {
println!("╔═══════════════════════════════════════════════════════════╗");
println!("║ 📁 DETECTED PROJECTS 📁 ║");
println!("╠═══════════════════════════════════════════════════════════╣");
if projects.is_empty() {
println!("║ No projects detected yet. ║");
println!("║ Add directories to watch with: st --daemon-watch <path> ║");
} else {
for p in projects.iter().take(10) {
println!("║ 📦 {:<53} ║", truncate_str(&p.name, 53));
println!("║ Type: {:<47} ║", p.project_type);
println!("║ Path: {:<47} ║", truncate_str(&p.path, 47));
if !p.key_files.is_empty() {
println!(
"║ Files: {:<46} ║",
truncate_str(&p.key_files.join(", "), 46)
);
}
}
if projects.len() > 10 {
println!(
"║ ... and {} more projects ║",
projects.len() - 10
);
}
}
println!("╚═══════════════════════════════════════════════════════════╝");
}
fn truncate_str(s: &str, max_len: usize) -> String {
if s.len() <= max_len {
s.to_string()
} else {
format!("{}...", &s[..max_len - 3])
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_client_creation() {
let client = DaemonClient::new(28428);
assert_eq!(client.port, 28428);
assert_eq!(client.base_url, "http://127.0.0.1:28428");
}
#[test]
fn test_default_port() {
let client = DaemonClient::default_port();
assert_eq!(client.port, DEFAULT_DAEMON_PORT);
}
#[tokio::test]
async fn test_status_when_not_running() {
let client = DaemonClient::new(59999);
let status = client.check_status().await;
assert!(matches!(
status,
DaemonStatus::NotRunning | DaemonStatus::Error(_)
));
}
}