use anyhow::Result;
use notify::{recommended_watcher, RecursiveMode, Watcher};
use std::collections::HashMap;
use std::path::Path;
use std::sync::mpsc::channel;
use tokio::fs;
#[derive(Debug)]
pub struct ApiChange {
pub function_name: String,
pub old_signature: String,
pub new_signature: String,
pub file_path: String,
pub is_breaking: bool,
}
pub struct DocSyncAgent {
watcher: Option<notify::RecommendedWatcher>,
api_cache: HashMap<String, String>,
watch_dir: String,
}
impl DocSyncAgent {
pub fn new(watch_dir: &str) -> Result<Self> {
Ok(Self {
watcher: None,
api_cache: HashMap::new(),
watch_dir: watch_dir.to_string(),
})
}
pub fn start_watching(&mut self) -> Result<()> {
let (tx, rx) = channel();
let mut watcher = recommended_watcher(move |res| tx.send(res).unwrap())
.map_err(|e| anyhow::anyhow!("Failed to create file watcher: {}", e))?;
watcher
.watch(Path::new(&self.watch_dir), RecursiveMode::Recursive)
.map_err(|e| anyhow::anyhow!("Failed to watch directory: {}", e))?;
self.watcher = Some(watcher);
std::thread::spawn(move || {
for res in rx {
match res {
Ok(event) => {
println!("File event: {:?}", event);
}
Err(e) => eprintln!("Watch error: {:?}", e),
}
}
});
Ok(())
}
pub async fn on_code_change(&self, event: ¬ify::Event) -> Result<()> {
for path in &event.paths {
let path_str = path.to_string_lossy().to_string();
if self.is_code_file(&path_str) {
let diff = self.git_diff(&path_str).await?;
let api_changes = self.parse_api_changes(&diff).await?;
self.update_readme(&api_changes).await?;
self.update_openapi_spec(&api_changes).await?;
self.update_inline_docs(&path_str, &api_changes).await?;
if self.has_breaking_changes(&api_changes) {
self.append_migration_guide(&api_changes).await?;
}
}
}
Ok(())
}
fn is_code_file(&self, path: &str) -> bool {
let extensions = [
"rs", "js", "ts", "py", "dart", "java", "cpp", "c", "go", "tsx", "jsx",
];
if let Some(ext) = Path::new(path).extension() {
if let Some(ext_str) = ext.to_str() {
return extensions.contains(&ext_str);
}
}
false
}
async fn git_diff(&self, file_path: &str) -> Result<String> {
let content = fs::read_to_string(file_path).await?;
Ok(content)
}
async fn parse_api_changes(&self, diff: &str) -> Result<Vec<ApiChange>> {
let mut changes = Vec::new();
let lines: Vec<&str> = diff.lines().collect();
for line in lines {
if line.contains("fn ") && (line.contains("+") || line.contains("-")) {
changes.push(ApiChange {
function_name: "example_function".to_string(),
old_signature: "old_signature".to_string(),
new_signature: "new_signature".to_string(),
file_path: "example.rs".to_string(),
is_breaking: false, });
}
}
Ok(changes)
}
async fn update_readme(&self, changes: &[ApiChange]) -> Result<()> {
let readme_path = Path::new(&self.watch_dir).join("README.md");
if readme_path.exists() {
let mut content = fs::read_to_string(&readme_path).await?;
if !changes.is_empty() {
let change_summary = self.format_api_changes_for_readme(changes);
if content.contains("## API Changes") {
let start = content.find("## API Changes").unwrap();
let remaining_content = &content[start + 15..];
let end_relative = remaining_content
.find("\n## ")
.unwrap_or(remaining_content.len());
let end = start + 15 + end_relative;
let prefix = &content[0..start];
let suffix = &content[end..];
content = format!("{}{}{}", prefix, change_summary, suffix);
} else {
content.push_str("\n\n");
content.push_str(&change_summary);
}
}
fs::write(&readme_path, content).await?;
} else {
let content = format!(
"# Project Documentation\n\n{}",
self.format_api_changes_for_readme(changes)
);
fs::write(&readme_path, content).await?;
}
Ok(())
}
fn format_api_changes_for_readme(&self, changes: &[ApiChange]) -> String {
if changes.is_empty() {
return "## API Changes\n\nNo recent API changes.".to_string();
}
let mut content = "## API Changes\n\n".to_string();
for change in changes {
content.push_str(&format!(
"- `{}`: {} → {}\n",
change.function_name, change.old_signature, change.new_signature
));
}
content
}
async fn update_openapi_spec(&self, changes: &[ApiChange]) -> Result<()> {
let openapi_path = Path::new(&self.watch_dir).join("openapi.yaml");
if openapi_path.exists() {
println!("OpenAPI spec would be updated with changes: {:?}", changes);
} else {
let openapi_json_path = Path::new(&self.watch_dir).join("openapi.json");
if openapi_json_path.exists() {
println!(
"OpenAPI JSON spec would be updated with changes: {:?}",
changes
);
}
}
Ok(())
}
async fn update_inline_docs(&self, file_path: &str, changes: &[ApiChange]) -> Result<()> {
let mut content = fs::read_to_string(file_path).await?;
for change in changes {
if content.contains(&change.function_name) {
let doc_comment = format!(
"/// Auto-generated documentation for `{}`\n",
change.function_name
);
if let Some(pos) = content.find(&format!("fn {}(", change.function_name)) {
content.insert_str(pos, &doc_comment);
}
}
}
fs::write(file_path, content).await?;
Ok(())
}
fn has_breaking_changes(&self, changes: &[ApiChange]) -> bool {
changes.iter().any(|change| change.is_breaking)
}
async fn append_migration_guide(&self, changes: &[ApiChange]) -> Result<()> {
let migration_path = Path::new(&self.watch_dir).join("MIGRATION_GUIDE.md");
let mut content = if migration_path.exists() {
fs::read_to_string(&migration_path).await?
} else {
"# Migration Guide\n\n".to_string()
};
let breaking_changes: Vec<&ApiChange> =
changes.iter().filter(|change| change.is_breaking).collect();
if !breaking_changes.is_empty() {
let timestamp = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
content.push_str(&format!("\n## Breaking Changes - {}\n\n", timestamp));
for change in breaking_changes {
content.push_str(&format!("### `{}`\n\n", change.function_name));
content.push_str(&format!("- **Before**: `{}`\n", change.old_signature));
content.push_str(&format!("- **After**: `{}`\n", change.new_signature));
content.push_str(
"- **Migration**: Update function calls to match the new signature\n\n",
);
}
}
fs::write(&migration_path, content).await?;
Ok(())
}
pub async fn sync_all_docs(&self) -> Result<()> {
let mut tasks = Vec::new();
let mut entries = tokio::fs::read_dir(&self.watch_dir).await?;
while let Some(entry) = entries.next_entry().await? {
if entry.file_type().await?.is_file()
&& self.is_code_file(&entry.path().to_string_lossy())
{
let file_path = entry.path().to_string_lossy().to_string();
tasks.push(async move {
let diff = self.git_diff(&file_path).await?;
let api_changes = self.parse_api_changes(&diff).await?;
self.update_inline_docs(&file_path, &api_changes).await?;
Ok::<(), anyhow::Error>(())
});
} else if entry.file_type().await?.is_dir() {
}
}
for task in tasks {
task.await?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_doc_sync_agent_creation() {
let agent = DocSyncAgent::new(".");
assert!(agent.is_ok());
}
#[test]
fn test_is_code_file() {
let agent = DocSyncAgent {
watcher: None,
api_cache: HashMap::new(),
watch_dir: ".".to_string(),
};
assert!(agent.is_code_file("test.rs"));
assert!(agent.is_code_file("src/main.js"));
assert!(!agent.is_code_file("README.md"));
assert!(!agent.is_code_file("image.png"));
}
}