use std::path::PathBuf;
use clap::Args;
use serde::Serialize;
use crate::output::OutputFormat;
use super::error::{DaemonError, DaemonResult};
use super::ipc::send_command;
use super::types::{DaemonCommand, DaemonResponse};
#[derive(Debug, Clone, Args)]
pub struct DaemonNotifyArgs {
pub file: PathBuf,
#[arg(long, short = 'p', default_value = ".")]
pub project: PathBuf,
}
#[derive(Debug, Clone, Serialize)]
pub struct DaemonNotifyOutput {
pub status: String,
pub dirty_count: usize,
pub threshold: usize,
pub reindex_triggered: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct DaemonNotifyErrorOutput {
pub status: String,
pub error: String,
}
impl DaemonNotifyArgs {
pub fn run(&self, format: OutputFormat, quiet: bool) -> anyhow::Result<()> {
let runtime = tokio::runtime::Runtime::new()?;
runtime.block_on(self.run_async(format, quiet))
}
async fn run_async(&self, format: OutputFormat, quiet: bool) -> anyhow::Result<()> {
let project = self.project.canonicalize().unwrap_or_else(|_| {
std::env::current_dir()
.unwrap_or_else(|_| PathBuf::from("."))
.join(&self.project)
});
let file = self.file.canonicalize().unwrap_or_else(|_| {
std::env::current_dir()
.unwrap_or_else(|_| PathBuf::from("."))
.join(&self.file)
});
if !file.starts_with(&project) {
let output = DaemonNotifyErrorOutput {
status: "error".to_string(),
error: format!(
"File '{}' is outside project root '{}'",
file.display(),
project.display()
),
};
if !quiet {
match format {
OutputFormat::Json | OutputFormat::Compact => {
println!("{}", serde_json::to_string_pretty(&output)?);
}
OutputFormat::Text | OutputFormat::Sarif | OutputFormat::Dot => {
eprintln!("Error: File '{}' is outside project root", file.display());
}
}
}
return Err(anyhow::anyhow!("File is outside project root"));
}
let cmd = DaemonCommand::Notify { file: file.clone() };
match send_command(&project, &cmd).await {
Ok(response) => self.handle_response(response, format, quiet),
Err(DaemonError::NotRunning) | Err(DaemonError::ConnectionRefused) => {
if !quiet {
match format {
OutputFormat::Json | OutputFormat::Compact => {
let output = DaemonNotifyOutput {
status: "ok".to_string(),
dirty_count: 0,
threshold: 20,
reindex_triggered: false,
message: Some(
"Daemon not running (notification ignored)".to_string(),
),
};
println!("{}", serde_json::to_string_pretty(&output)?);
}
OutputFormat::Text | OutputFormat::Sarif | OutputFormat::Dot => {
}
}
}
Ok(())
}
Err(e) => {
if !quiet {
match format {
OutputFormat::Json | OutputFormat::Compact => {
let output = DaemonNotifyOutput {
status: "ok".to_string(),
dirty_count: 0,
threshold: 20,
reindex_triggered: false,
message: Some(format!("Notification failed: {} (ignored)", e)),
};
println!("{}", serde_json::to_string_pretty(&output)?);
}
OutputFormat::Text | OutputFormat::Sarif | OutputFormat::Dot => {
}
}
}
Ok(())
}
}
}
fn handle_response(
&self,
response: DaemonResponse,
format: OutputFormat,
quiet: bool,
) -> anyhow::Result<()> {
match response {
DaemonResponse::NotifyResponse {
status,
dirty_count,
threshold,
reindex_triggered,
} => {
let output = DaemonNotifyOutput {
status,
dirty_count,
threshold,
reindex_triggered,
message: None,
};
if !quiet {
match format {
OutputFormat::Json | OutputFormat::Compact => {
println!("{}", serde_json::to_string_pretty(&output)?);
}
OutputFormat::Text | OutputFormat::Sarif | OutputFormat::Dot => {
if reindex_triggered {
println!("Reindex triggered ({}/{} files)", dirty_count, threshold);
} else {
println!("Tracked: {}/{} files", dirty_count, threshold);
}
}
}
}
Ok(())
}
DaemonResponse::Status { status, message } => {
let output = DaemonNotifyOutput {
status: status.clone(),
dirty_count: 0,
threshold: 20,
reindex_triggered: false,
message,
};
if !quiet {
match format {
OutputFormat::Json | OutputFormat::Compact => {
println!("{}", serde_json::to_string_pretty(&output)?);
}
OutputFormat::Text | OutputFormat::Sarif | OutputFormat::Dot => {
println!("Status: {}", status);
}
}
}
Ok(())
}
DaemonResponse::Error { error, .. } => {
let output = DaemonNotifyErrorOutput {
status: "error".to_string(),
error: error.clone(),
};
if !quiet {
match format {
OutputFormat::Json | OutputFormat::Compact => {
println!("{}", serde_json::to_string_pretty(&output)?);
}
OutputFormat::Text | OutputFormat::Sarif | OutputFormat::Dot => {
eprintln!("Error: {}", error);
}
}
}
Ok(())
}
_ => {
Ok(())
}
}
}
}
pub async fn cmd_notify(args: DaemonNotifyArgs) -> DaemonResult<()> {
let project = args.project.canonicalize().unwrap_or_else(|_| {
std::env::current_dir()
.unwrap_or_else(|_| PathBuf::from("."))
.join(&args.project)
});
let file = args.file.canonicalize().unwrap_or_else(|_| {
std::env::current_dir()
.unwrap_or_else(|_| PathBuf::from("."))
.join(&args.file)
});
if !file.starts_with(&project) {
return Err(DaemonError::PermissionDenied { path: file });
}
let cmd = DaemonCommand::Notify { file };
let response = send_command(&project, &cmd).await?;
match response {
DaemonResponse::NotifyResponse {
dirty_count,
threshold,
reindex_triggered,
..
} => {
if reindex_triggered {
println!("Reindex triggered ({}/{} files)", dirty_count, threshold);
} else {
println!("Tracked: {}/{} files", dirty_count, threshold);
}
Ok(())
}
DaemonResponse::Error { error, .. } => {
eprintln!("Error: {}", error);
Ok(()) }
_ => Ok(()),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn test_daemon_notify_args_default() {
let args = DaemonNotifyArgs {
file: PathBuf::from("test.rs"),
project: PathBuf::from("."),
};
assert_eq!(args.file, PathBuf::from("test.rs"));
assert_eq!(args.project, PathBuf::from("."));
}
#[test]
fn test_daemon_notify_args_with_project() {
let args = DaemonNotifyArgs {
file: PathBuf::from("/test/project/src/main.rs"),
project: PathBuf::from("/test/project"),
};
assert_eq!(args.file, PathBuf::from("/test/project/src/main.rs"));
assert_eq!(args.project, PathBuf::from("/test/project"));
}
#[test]
fn test_daemon_notify_output_serialization() {
let output = DaemonNotifyOutput {
status: "ok".to_string(),
dirty_count: 5,
threshold: 20,
reindex_triggered: false,
message: None,
};
let json = serde_json::to_string(&output).unwrap();
assert!(json.contains("ok"));
assert!(json.contains("5"));
assert!(json.contains("20"));
assert!(json.contains("false"));
}
#[test]
fn test_daemon_notify_output_reindex_triggered() {
let output = DaemonNotifyOutput {
status: "ok".to_string(),
dirty_count: 20,
threshold: 20,
reindex_triggered: true,
message: None,
};
let json = serde_json::to_string(&output).unwrap();
assert!(json.contains("true"));
}
#[test]
fn test_daemon_notify_error_output_serialization() {
let output = DaemonNotifyErrorOutput {
status: "error".to_string(),
error: "File outside project root".to_string(),
};
let json = serde_json::to_string(&output).unwrap();
assert!(json.contains("error"));
assert!(json.contains("File outside project root"));
}
#[tokio::test]
async fn test_daemon_notify_file_outside_project() {
let temp = TempDir::new().unwrap();
let outside_file = TempDir::new().unwrap();
let test_file = outside_file.path().join("outside.rs");
fs::write(&test_file, "fn main() {}").unwrap();
let args = DaemonNotifyArgs {
file: test_file.clone(),
project: temp.path().to_path_buf(),
};
let result = cmd_notify(args).await;
assert!(result.is_err());
assert!(matches!(result, Err(DaemonError::PermissionDenied { .. })));
}
#[tokio::test]
async fn test_daemon_notify_file_inside_project() {
let temp = TempDir::new().unwrap();
let test_file = temp.path().join("test.rs");
fs::write(&test_file, "fn main() {}").unwrap();
let args = DaemonNotifyArgs {
file: test_file.clone(),
project: temp.path().to_path_buf(),
};
let result = cmd_notify(args).await;
assert!(result.is_err());
assert!(matches!(result, Err(DaemonError::NotRunning)));
}
#[tokio::test]
async fn test_daemon_notify_silent_when_not_running() {
let temp = TempDir::new().unwrap();
let test_file = temp.path().join("test.rs");
fs::write(&test_file, "fn main() {}").unwrap();
let args = DaemonNotifyArgs {
file: test_file.clone(),
project: temp.path().to_path_buf(),
};
let result = args.run_async(OutputFormat::Json, true).await;
assert!(result.is_ok());
}
}