use anyhow::{Context, Result};
use candid::{CandidType, Deserialize, Principal};
use clap::{Parser, Subcommand};
use ic_agent::Agent;
use ic_utils::call::SyncCall;
use ic_utils::canister::Canister;
use notify::{Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
use serde::Serialize;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::mpsc::channel;
use tracing::{debug, error, info, warn};
mod constants;
use constants::{get_default_canister_id, resolve_network_url};
#[derive(Parser)]
#[command(name = "dfxmon")]
#[command(about = "CLI tool for interacting with dfxmon canister on the Internet Computer")]
#[command(version)]
struct Cli {
#[arg(long)]
canister_id: Option<String>,
#[arg(long, default_value = "local")]
network: String,
#[arg(short, long)]
verbose: bool,
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
Create {
name: String,
#[arg(long, default_value = "./dfx.json")]
dfx_json: PathBuf,
#[arg(long)]
auto_deploy: bool,
#[arg(long, default_value = "1000")]
debounce_ms: u64,
},
Watch {
project: String,
#[arg(long, default_value = "all")]
filter: String,
},
Deploy {
project: String,
#[arg(long)]
canister: Option<String>,
#[arg(long)]
force: bool,
},
Status {
project: String,
},
List,
History {
project: Option<String>,
#[arg(long, default_value = "10")]
limit: u32,
},
Stats,
Health,
}
#[derive(CandidType, Serialize, Deserialize, Debug)]
struct CreateProjectRequest {
name: String,
dfx_json_content: String,
auto_deploy: bool,
debounce_ms: Option<u64>,
ignore_patterns: Option<Vec<String>>,
}
#[derive(CandidType, Serialize, Deserialize, Debug)]
struct WatchRequest {
project_name: String,
canister_filter: Option<String>,
}
#[derive(CandidType, Serialize, Deserialize, Debug)]
struct DeployRequest {
project_name: String,
canister_name: Option<String>,
force: bool,
}
#[derive(CandidType, Serialize, Deserialize, Debug)]
struct FileChangeEvent {
file_path: String,
change_type: ChangeType,
timestamp: u64,
project_name: String,
}
#[derive(CandidType, Serialize, Deserialize, Debug)]
enum ChangeType {
Created,
Modified,
Deleted,
}
#[tokio::main]
async fn main() -> Result<()> {
let cli = Cli::parse();
let log_level = if cli.verbose { "debug" } else { "info" };
tracing_subscriber::fmt()
.with_env_filter(format!("dfxmon={}", log_level))
.init();
info!("Starting dfxmon CLI v{}", env!("CARGO_PKG_VERSION"));
let network_url = resolve_network_url(&cli.network);
info!("Connecting to network: {}", network_url);
let canister_id = cli.canister_id
.or_else(|| std::env::var("DFXMON_CANISTER_ID").ok())
.unwrap_or_else(|| {
let default_id = get_default_canister_id(&network_url);
info!("Using default dfxmon canister ID for network {}: {}", network_url, default_id);
debug!("Network URL: {}, Selected canister ID: {}", network_url, default_id);
default_id.to_string()
});
let canister_principal =
Principal::from_text(&canister_id).context("Invalid canister ID format")?;
let agent = Agent::builder()
.with_url(&network_url)
.build()
.context("Failed to create IC agent")?;
if network_url.contains("localhost") || network_url.contains("127.0.0.1") {
agent
.fetch_root_key()
.await
.context("Failed to fetch root key for local development")?;
}
let canister = Canister::builder()
.with_agent(&agent)
.with_canister_id(canister_principal)
.build()
.context("Failed to create canister interface")?;
match cli.command {
Commands::Create {
name,
dfx_json,
auto_deploy,
debounce_ms,
} => {
create_project(&canister, name, dfx_json, auto_deploy, debounce_ms).await?;
}
Commands::Watch { project, filter } => {
start_watching(&canister, project, filter).await?;
}
Commands::Deploy {
project,
canister: canister_name,
force,
} => {
deploy_project(&canister, project, canister_name, force).await?;
}
Commands::Status { project } => {
get_project_status(&canister, project).await?;
}
Commands::List => {
list_projects(&canister).await?;
}
Commands::History { project, limit } => {
get_deployment_history(&canister, project, limit).await?;
}
Commands::Stats => {
get_statistics(&canister).await?;
}
Commands::Health => {
health_check(&canister).await?;
}
}
Ok(())
}
async fn create_project(
canister: &Canister<'_>,
name: String,
dfx_json_path: PathBuf,
auto_deploy: bool,
debounce_ms: u64,
) -> Result<()> {
info!("Creating project: {}", name);
let dfx_json_content = fs::read_to_string(&dfx_json_path)
.with_context(|| format!("Failed to read dfx.json from {}", dfx_json_path.display()))?;
let request = CreateProjectRequest {
name: name.clone(),
dfx_json_content,
auto_deploy,
debounce_ms: Some(debounce_ms),
ignore_patterns: None,
};
let response: (Result<String, String>,) = canister
.update("create_project")
.with_arg(request)
.build()
.call_and_wait()
.await
.context("Failed to call create_project")?;
match response.0 {
Ok(msg) => {
info!("✅ Project created successfully: {}", msg);
}
Err(e) => {
error!("❌ Failed to create project: {}", e);
return Err(anyhow::anyhow!("{}", e));
}
}
Ok(())
}
async fn start_watching(
canister: &Canister<'_>,
project_name: String,
filter: String,
) -> Result<()> {
info!("Starting file watcher for project: {}", project_name);
let watch_request = WatchRequest {
project_name: project_name.clone(),
canister_filter: if filter == "all" { None } else { Some(filter) },
};
let response: (Result<String, String>,) = canister
.update("start_watching")
.with_arg(watch_request)
.build()
.call_and_wait()
.await
.context("Failed to call start_watching")?;
match response.0 {
Ok(msg) => {
info!("✅ Started watching: {}", msg);
}
Err(e) => {
error!("❌ Failed to start watching: {}", e);
return Err(anyhow::anyhow!("{}", e));
}
}
start_local_file_watcher(canister, project_name).await?;
Ok(())
}
async fn start_local_file_watcher(canister: &Canister<'_>, project_name: String) -> Result<()> {
info!("🔍 Starting local file watcher...");
let (tx, rx) = channel();
let mut watcher = RecommendedWatcher::new(
move |res: Result<Event, notify::Error>| {
if let Err(e) = tx.send(res) {
error!("Failed to send file event: {}", e);
}
},
notify::Config::default(),
)?;
watcher
.watch(Path::new("."), RecursiveMode::Recursive)
.context("Failed to start watching current directory")?;
info!("👀 Watching current directory for file changes...");
loop {
match rx.recv() {
Ok(Ok(event)) => {
if let Err(e) = handle_file_event(canister, &project_name, event).await {
error!("Error handling file event: {}", e);
}
}
Ok(Err(e)) => {
error!("File watcher error: {}", e);
}
Err(e) => {
error!("Channel error: {}", e);
break;
}
}
}
Ok(())
}
async fn handle_file_event(
canister: &Canister<'_>,
project_name: &str,
event: Event,
) -> Result<()> {
match event.kind {
EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_) => {}
_ => return Ok(()),
}
for path in event.paths {
if should_process_path(&path) {
let change_type = match event.kind {
EventKind::Create(_) => ChangeType::Created,
EventKind::Modify(_) => ChangeType::Modified,
EventKind::Remove(_) => ChangeType::Deleted,
_ => continue,
};
let file_event = FileChangeEvent {
file_path: path.to_string_lossy().to_string(),
change_type,
timestamp: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos() as u64,
project_name: project_name.to_string(),
};
debug!("📝 File change: {:?}", file_event);
let response: (Result<String, String>,) = canister
.update("notify_file_change")
.with_arg(file_event)
.build()
.call_and_wait()
.await
.context("Failed to notify file change")?;
match response.0 {
Ok(msg) => {
debug!("File change processed: {}", msg);
}
Err(e) => {
warn!("Failed to process file change: {}", e);
}
}
}
}
Ok(())
}
fn should_process_path(path: &std::path::Path) -> bool {
let path_str = path.to_string_lossy();
let ignore_patterns = [
".dfx/",
"target/",
"node_modules/",
".git/",
"dist/",
"build/",
".DS_Store",
];
for pattern in &ignore_patterns {
if path_str.contains(pattern) {
return false;
}
}
if path_str.ends_with(".log")
|| path_str.ends_with(".tmp")
|| path_str.ends_with(".swp")
|| path_str.ends_with("~")
{
return false;
}
if let Some(extension) = path.extension() {
matches!(
extension.to_str(),
Some("rs")
| Some("mo")
| Some("js")
| Some("ts")
| Some("jsx")
| Some("tsx")
| Some("html")
| Some("css")
| Some("scss")
| Some("json")
| Some("toml")
| Some("did")
| Some("md")
)
} else {
true
}
}
async fn deploy_project(
canister: &Canister<'_>,
project_name: String,
canister_name: Option<String>,
force: bool,
) -> Result<()> {
info!("Deploying project: {}", project_name);
let request = DeployRequest {
project_name,
canister_name,
force,
};
let response: (Result<String, String>,) = canister
.update("deploy")
.with_arg(request)
.build()
.call_and_wait()
.await
.context("Failed to call deploy")?;
match response.0 {
Ok(msg) => {
info!("✅ Deployment successful: {}", msg);
}
Err(e) => {
error!("❌ Deployment failed: {}", e);
return Err(anyhow::anyhow!("{}", e));
}
}
Ok(())
}
async fn get_project_status(canister: &Canister<'_>, project_name: String) -> Result<()> {
info!("Getting status for project: {}", project_name);
let response: (Result<String, String>,) = canister
.query("get_project_status")
.with_arg(project_name)
.build()
.call()
.await
.context("Failed to call get_project_status")?;
match response.0 {
Ok(status_json) => {
println!("📊 Project Status:");
match serde_json::from_str::<serde_json::Value>(&status_json) {
Ok(status) => println!("{}", serde_json::to_string_pretty(&status)?),
Err(_) => println!("{}", status_json), }
}
Err(e) => {
error!("❌ Failed to get project status: {}", e);
return Err(anyhow::anyhow!("{}", e));
}
}
Ok(())
}
async fn list_projects(canister: &Canister<'_>) -> Result<()> {
info!("Listing all projects...");
let projects: (Vec<String>,) = canister
.query("list_projects")
.build()
.call()
.await
.context("Failed to call list_projects")?;
println!("📋 Projects:");
for project in projects.0 {
println!(" - {}", project);
}
Ok(())
}
async fn get_deployment_history(
canister: &Canister<'_>,
project: Option<String>,
limit: u32,
) -> Result<()> {
info!("Getting deployment history...");
let history: (String,) = canister
.query("get_deployment_history")
.with_arg((project, Some(limit)))
.build()
.call()
.await
.context("Failed to call get_deployment_history")?;
println!("📈 Deployment History:");
match serde_json::from_str::<serde_json::Value>(&history.0) {
Ok(parsed) => println!("{}", serde_json::to_string_pretty(&parsed)?),
Err(_) => println!("{}", history.0), }
Ok(())
}
async fn get_statistics(canister: &Canister<'_>) -> Result<()> {
info!("Getting dfxmon statistics...");
let stats: (Vec<(String, u32)>,) = canister
.query("get_statistics")
.build()
.call()
.await
.context("Failed to call get_statistics")?;
println!("📊 dfxmon Statistics:");
for (key, value) in stats.0 {
println!(" {}: {}", key, value);
}
Ok(())
}
async fn health_check(canister: &Canister<'_>) -> Result<()> {
info!("Performing health check...");
let health: (String,) = canister
.query("health_check")
.build()
.call()
.await
.context("Failed to call health_check")?;
println!("🏥 Health Check: {}", health.0);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
#[test]
fn test_should_process_path_rust_files() {
assert!(should_process_path(Path::new("src/lib.rs")));
assert!(should_process_path(Path::new("src/main.rs")));
assert!(should_process_path(Path::new("backend/src/lib.rs")));
}
#[test]
fn test_should_process_path_motoko_files() {
assert!(should_process_path(Path::new("src/main.mo")));
assert!(should_process_path(Path::new("backend/main.mo")));
}
#[test]
fn test_should_process_path_frontend_files() {
assert!(should_process_path(Path::new("src/index.js")));
assert!(should_process_path(Path::new("src/App.tsx")));
assert!(should_process_path(Path::new("src/style.css")));
assert!(should_process_path(Path::new("public/index.html")));
}
#[test]
fn test_should_process_path_config_files() {
assert!(should_process_path(Path::new("dfx.json")));
assert!(should_process_path(Path::new("Cargo.toml")));
assert!(should_process_path(Path::new("package.json")));
}
#[test]
fn test_should_ignore_build_directories() {
assert!(!should_process_path(Path::new(
".dfx/local/canister_ids.json"
)));
assert!(!should_process_path(Path::new(
"target/debug/deps/lib.rlib"
)));
assert!(!should_process_path(Path::new(
"node_modules/react/index.js"
)));
assert!(!should_process_path(Path::new("dist/bundle.js")));
assert!(!should_process_path(Path::new("build/static/js/main.js")));
}
#[test]
fn test_should_ignore_system_files() {
assert!(!should_process_path(Path::new(".DS_Store")));
assert!(!should_process_path(Path::new("file.log")));
assert!(!should_process_path(Path::new("temp.tmp")));
assert!(!should_process_path(Path::new("file.swp")));
assert!(!should_process_path(Path::new("backup~")));
}
#[test]
fn test_should_ignore_git_directory() {
assert!(!should_process_path(Path::new(".git/config")));
assert!(!should_process_path(Path::new(".git/hooks/pre-commit")));
}
}