diff --git a/README.md b/README.md index 674a176..386fad8 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ WayLog CLI is a lightweight tool written in Rust that automatically saves your A ## ✨ Features -- **🔄 Auto-Sync**: Real-time synchronization of chat history to `.waylog/history/` as you type. +- **🔄 Auto-Sync**: Real-time synchronization of chat history to `~/.waylog/history/` as you type. - **📦 Full History Recovery**: The `pull` command scans your entire machine to recover past sessions into the current project. - **📝 Markdown Native**: All history is saved as high-quality Markdown files with frontmatter metadata. @@ -64,7 +64,7 @@ waylog run codex ### 2. Full Sync / Recover History (`pull`) -Scans your local AI provider storage and "pulls" all relevant sessions into your project's `.waylog` folder. +Scans your local AI provider storage and "pulls" all relevant sessions into your project's `~/.waylog/history/` folder. diff --git a/README_zh.md b/README_zh.md index 6610b2d..c22f6c5 100644 --- a/README_zh.md +++ b/README_zh.md @@ -13,7 +13,7 @@ WayLog CLI 是一个轻量级的工具,自动捕捉并存档你的 AI 编程 ## ✨ 特性 -- **🔄 自动同步**:实时同步聊天历史至 `.waylog/history/`,边聊边记。 +- **🔄 自动同步**:实时同步聊天历史至 `~/.waylog/history/`,边聊边记。 - **📦 全量历史恢复**:使用 `pull` 命令扫描全机,将过去或丢失的会话恢复到当前项目中。 - **📝 Markdown 原生**:所有历史记录均保存为带 Frontmatter 元数据的高质量 Markdown 文件。 @@ -52,7 +52,7 @@ waylog run gemini ### 2. 全量同步 / 恢复历史 (`pull`) -扫描本地 AI 供应商的存储,并将所有相关的会话“拉取”到项目的 `.waylog` 文件夹中。 +扫描本地 AI 供应商的存储,并将所有相关的会话“拉取”到项目的 `~/.waylog/history/` 文件夹中。 diff --git a/scripts/test-local-integration.sh b/scripts/test-local-integration.sh index 0fb1ee1..5739b03 100755 --- a/scripts/test-local-integration.sh +++ b/scripts/test-local-integration.sh @@ -136,9 +136,16 @@ test_case "Non-terminal output (piped, no color)" "cargo run -- --help 2>&1 | ca # 6. Logging Tests section "Logging Tests" +# Determine project name to locate log file in ~/.waylog/logs/ +PROJECT_NAME=$(basename "$PWD") +if [ -z "$PROJECT_NAME" ]; then + PROJECT_NAME="default" +fi +LOG_DIR="$HOME/.waylog/logs/$PROJECT_NAME" + # Check if log file is created in verbose mode -if [ -d ".waylog/logs" ]; then - test_case "Log file creation (verbose mode)" "[ -f .waylog/logs/waylog.log.\$(date +%Y-%m-%d) ]" 0 +if [ -d "$LOG_DIR" ]; then + test_case "Log file creation (verbose mode)" "[ -f \"$LOG_DIR/waylog.log.\$(date +%Y-%m-%d)\" ]" 0 else echo -e " ${YELLOW}⚠ SKIP${NC} (log directory not found, run with --verbose first)" ((SKIPPED++)) diff --git a/src/cli.rs b/src/cli.rs index a6c7412..d726ef7 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -31,7 +31,7 @@ pub enum OutputFormat { pub enum Commands { /// Run an AI CLI tool and automatically sync its chat history Run { - /// The AI tool to run (codex, claude, gemini) + /// The AI tool to run (codex, claude, gemini, coco) agent: Option, /// Additional arguments to pass to the agent diff --git a/src/init.rs b/src/init.rs index 137cc7f..76988e4 100644 --- a/src/init.rs +++ b/src/init.rs @@ -34,7 +34,7 @@ pub fn resolve_project_root(command: &Commands, output: &mut Output) -> Result<( None => { // Interactive prompt for initialization let current_dir = std::env::current_dir()?; - let waylog_path = current_dir.join(WAYLOG_DIR); + let waylog_path = crate::utils::path::get_waylog_dir(¤t_dir); output.not_initialized()?; output.init_prompt(&waylog_path)?; @@ -78,7 +78,7 @@ pub fn setup_logging(project_root: &Path, verbose: bool, quiet: bool) -> Result< // Build subscriber with conditional layers if verbose { - let log_dir = project_root.join(WAYLOG_DIR).join(subdirs::LOGS); + let log_dir = crate::utils::path::get_log_dir(project_root); // Create log directory if it doesn't exist std::fs::create_dir_all(&log_dir)?; diff --git a/src/providers/claude.rs b/src/providers/claude.rs index 53db2a7..fd7c92d 100644 --- a/src/providers/claude.rs +++ b/src/providers/claude.rs @@ -363,7 +363,6 @@ struct ClaudeUsage { #[cfg(test)] mod tests { use super::*; - use crate::providers::base::{MessageRole, Provider}; // Helper to create a user message event with content fn create_user_event(content: &str) -> ClaudeEvent { diff --git a/src/providers/coco.rs b/src/providers/coco.rs new file mode 100644 index 0000000..a45648d --- /dev/null +++ b/src/providers/coco.rs @@ -0,0 +1,221 @@ +use crate::error::{Result, WaylogError}; +use crate::providers::base::*; +use crate::utils::path; +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use serde::Deserialize; +use std::path::{Path, PathBuf}; +use tokio::fs; + +pub struct CocoProvider; + +impl CocoProvider { + pub fn new() -> Self { + Self + } +} + +#[async_trait] +impl Provider for CocoProvider { + fn name(&self) -> &str { + "coco" + } + + fn data_dir(&self) -> Result { + let home = path::home_dir()?; + Ok(home.join(".cache").join("coco").join("sessions")) + } + + fn session_dir(&self, _project_path: &Path) -> Result { + // Coco stores sessions in a flat directory structure, not per project + // So we return the main sessions directory + self.data_dir() + } + + async fn find_latest_session(&self, project_path: &Path) -> Result> { + let candidates = self.get_all_sessions(project_path).await?; + Ok(candidates.into_iter().next()) + } + + async fn get_all_sessions(&self, project_path: &Path) -> Result> { + let session_dir = self.data_dir()?; + + if !session_dir.exists() { + return Ok(Vec::new()); + } + + let mut entries = fs::read_dir(&session_dir).await?; + let mut candidates = Vec::new(); + + while let Some(entry) = entries.next_entry().await? { + let path = entry.path(); + if path.is_dir() { + // Check session.json for cwd match + let session_json_path = path.join("session.json"); + if session_json_path.exists() { + if let Ok(content) = fs::read_to_string(&session_json_path).await { + if let Ok(session_data) = serde_json::from_str::(&content) + { + // Normalize paths for comparison + let session_cwd = std::fs::canonicalize(&session_data.metadata.cwd) + .unwrap_or_else(|_| session_data.metadata.cwd.clone()); + + let target_cwd = std::fs::canonicalize(project_path) + .unwrap_or_else(|_| project_path.to_path_buf()); + + if session_cwd == target_cwd { + // Found a match, use events.jsonl as the session file + let events_path = path.join("events.jsonl"); + if events_path.exists() { + // Parse updated_at to sort + let updated_at = + DateTime::parse_from_rfc3339(&session_data.updated_at) + .map(|dt| dt.with_timezone(&Utc)) + .unwrap_or_else(|_| Utc::now()); + + candidates.push((events_path, updated_at)); + } + } + } + } + } + } + } + + // Sort by updated_at, newest first + candidates.sort_by(|a, b| b.1.cmp(&a.1)); + + Ok(candidates.into_iter().map(|(p, _)| p).collect()) + } + + async fn parse_session(&self, file_path: &Path) -> Result { + // file_path is events.jsonl + // session.json is in the same directory + let session_dir = file_path.parent().ok_or_else(|| { + WaylogError::PathError("Could not find parent directory of session file".to_string()) + })?; + + let session_json_path = session_dir.join("session.json"); + let session_content = fs::read_to_string(&session_json_path).await?; + let session_data: CocoSessionData = + serde_json::from_str(&session_content).map_err(WaylogError::Json)?; + + let events_content = fs::read_to_string(file_path).await?; + let mut messages = Vec::new(); + + for line in events_content.lines() { + if line.trim().is_empty() { + continue; + } + + if let Ok(event) = serde_json::from_str::(line) { + if let Some(msg) = self.parse_event(event) { + messages.push(msg); + } + } + } + + let started_at = DateTime::parse_from_rfc3339(&session_data.created_at) + .map(|dt| dt.with_timezone(&Utc)) + .unwrap_or_else(|_| Utc::now()); + + let updated_at = DateTime::parse_from_rfc3339(&session_data.updated_at) + .map(|dt| dt.with_timezone(&Utc)) + .unwrap_or(started_at); + + Ok(ChatSession { + session_id: session_data.id, + provider: self.name().to_string(), + project_path: session_data.metadata.cwd, + started_at, + updated_at, + messages, + }) + } + + fn is_installed(&self) -> bool { + self.data_dir().map(|d| d.exists()).unwrap_or(false) + } + + fn command(&self) -> &str { + "coco" // Assumed command name + } +} + +impl CocoProvider { + fn parse_event(&self, event: CocoEventLine) -> Option { + let (role, content) = if let Some(agent_start) = event.agent_start { + // User input + if let Some(first_input) = agent_start.input.first() { + (MessageRole::User, first_input.content.clone()) + } else { + return None; + } + } else if let Some(msg_event) = event.message { + // Assistant output + let role = match msg_event.message.role.as_str() { + "user" => MessageRole::User, + "assistant" => MessageRole::Assistant, + _ => return None, + }; + (role, msg_event.message.content) + } else { + return None; + }; + + if content.is_empty() { + return None; + } + + let timestamp = DateTime::parse_from_rfc3339(&event.created_at) + .map(|dt| dt.with_timezone(&Utc)) + .unwrap_or_else(|_| Utc::now()); + + Some(ChatMessage { + id: event.id, + timestamp, + role, + content, + metadata: MessageMetadata::default(), + }) + } +} + +// Coco JSON structures + +#[derive(Debug, Deserialize)] +struct CocoSessionData { + id: String, + created_at: String, + updated_at: String, + metadata: CocoSessionMetadata, +} + +#[derive(Debug, Deserialize)] +struct CocoSessionMetadata { + cwd: PathBuf, +} + +#[derive(Debug, Deserialize)] +struct CocoEventLine { + id: String, + created_at: String, + agent_start: Option, + message: Option, +} + +#[derive(Debug, Deserialize)] +struct CocoAgentStart { + input: Vec, +} + +#[derive(Debug, Deserialize)] +struct CocoMessageEvent { + message: CocoMessageContent, +} + +#[derive(Debug, Deserialize)] +struct CocoMessageContent { + role: String, + content: String, +} diff --git a/src/providers/mod.rs b/src/providers/mod.rs index a4a05e2..0042274 100644 --- a/src/providers/mod.rs +++ b/src/providers/mod.rs @@ -1,5 +1,6 @@ pub mod base; pub mod claude; +pub mod coco; pub mod codex; pub mod gemini; @@ -12,6 +13,7 @@ pub fn get_provider(name: &str) -> Result> { "codex" => Ok(Arc::new(codex::CodexProvider::new())), "claude" | "claude-code" => Ok(Arc::new(claude::ClaudeProvider::new())), "gemini" => Ok(Arc::new(gemini::GeminiProvider::new())), + "coco" => Ok(Arc::new(coco::CocoProvider::new())), _ => Err(WaylogError::ProviderNotFound(name.to_string())), } } @@ -23,9 +25,10 @@ pub fn all_providers() -> Vec> { Arc::new(codex::CodexProvider::new()), Arc::new(claude::ClaudeProvider::new()), Arc::new(gemini::GeminiProvider::new()), + Arc::new(coco::CocoProvider::new()), ] } /// Get a list of supported provider names pub fn list_providers() -> Vec<&'static str> { - vec!["claude", "gemini", "codex"] + vec!["claude", "gemini", "codex", "coco"] } diff --git a/src/utils/path.rs b/src/utils/path.rs index f403fff..72f30e5 100644 --- a/src/utils/path.rs +++ b/src/utils/path.rs @@ -1,12 +1,26 @@ -use crate::error::{Result, WaylogError}; +use crate::error::Result; use crate::init::{subdirs, WAYLOG_DIR}; use sha2::{Digest, Sha256}; use std::path::{Path, PathBuf}; /// Get the home directory in a cross-platform way pub fn home_dir() -> Result { - home::home_dir() - .ok_or_else(|| WaylogError::PathError("Could not find home directory".to_string())) + #[cfg(test)] + { + // Use a unique directory per test run to prevent tests from modifying + // the actual user's home directory. We use thread id to make it more unique. + let thread_id = format!("{:?}", std::thread::current().id()); + let sanitized_id = + thread_id.replace(&['T', 'h', 'r', 'e', 'a', 'd', 'I', 'd', '(', ')'][..], ""); + let test_home = std::env::temp_dir().join(format!("waylog_test_home_{}", sanitized_id)); + let _ = std::fs::create_dir_all(&test_home); + return Ok(test_home); + } + + #[cfg(not(test))] + home::home_dir().ok_or_else(|| { + crate::error::WaylogError::PathError("Could not find home directory".to_string()) + }) } /// Get the data directory for AI tools @@ -60,21 +74,54 @@ pub fn encode_path_gemini(path: &Path) -> String { format!("{:x}", hasher.finalize()) } -/// Get the .waylog/history directory for the current project +/// Get the .waylog/history directory for the current project in the user's home directory pub fn get_waylog_dir(project_dir: &Path) -> PathBuf { - project_dir.join(WAYLOG_DIR).join(subdirs::HISTORY) + let home = home_dir().unwrap_or_else(|_| PathBuf::from(".")); + let project_name = project_dir + .file_name() + .unwrap_or_default() + .to_string_lossy() + .to_string(); + + // Fallback to "default" if project_name is empty + let dir_name = if project_name.is_empty() { + "default".to_string() + } else { + project_name + }; + + home.join(WAYLOG_DIR).join(subdirs::HISTORY).join(dir_name) +} + +/// Get the .waylog/logs directory for the current project in the user's home directory +pub fn get_log_dir(project_dir: &Path) -> PathBuf { + let home = home_dir().unwrap_or_else(|_| PathBuf::from(".")); + let project_name = project_dir + .file_name() + .unwrap_or_default() + .to_string_lossy() + .to_string(); + + // Fallback to "default" if project_name is empty + let dir_name = if project_name.is_empty() { + "default".to_string() + } else { + project_name + }; + + home.join(WAYLOG_DIR).join(subdirs::LOGS).join(dir_name) } -/// Find the project root by looking for .waylog folder or .git folder +/// Find the project root by looking for .git folder /// moving upwards from the current directory. /// If we reach the home directory or the system root without finding a marker, -/// returns the current directory to avoid treat the whole home as a project. +/// returns the current directory. pub fn find_project_root() -> Option { let current_dir = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); let home = home_dir().ok(); for path in current_dir.ancestors() { - if path.join(WAYLOG_DIR).is_dir() { + if path.join(".git").is_dir() { return Some(path.to_path_buf()); } @@ -216,10 +263,9 @@ mod tests { let project_dir = std::env::temp_dir().join("test-project"); let waylog_dir = get_waylog_dir(&project_dir); - let expected = project_dir.join(".waylog").join("history"); + let home = home_dir().unwrap_or_else(|_| PathBuf::from(".")); + let expected = home.join(".waylog").join("history").join("test-project"); assert_eq!(waylog_dir, expected); - // Check path ends with correct components (platform-independent) - assert!(waylog_dir.ends_with(Path::new(".waylog").join("history"))); } #[test] @@ -251,9 +297,9 @@ mod tests { let project_root = temp_dir.path().join("project"); let subdir = project_root.join("subdir").join("deep"); - // Create project root directory and .waylog directory + // Create project root directory and .git directory fs::create_dir_all(&subdir).unwrap(); - fs::create_dir_all(project_root.join(".waylog")).unwrap(); + fs::create_dir_all(project_root.join(".git")).unwrap(); // Save current working directory let original_dir = std::env::current_dir().unwrap(); @@ -266,8 +312,8 @@ mod tests { assert!(found_root.is_some()); let found = found_root.unwrap(); - // Verify the found path contains .waylog directory - assert!(found.join(".waylog").exists()); + // Verify the found path contains .git directory + assert!(found.join(".git").exists()); // Compare paths by checking they resolve to the same directory // Use file_name to avoid issues with different path representations assert_eq!( @@ -282,7 +328,7 @@ mod tests { #[test] fn test_find_project_root_not_found() { - // Create temporary directory but don't create .waylog + // Create temporary directory but don't create .git let temp_dir = TempDir::new().unwrap(); let subdir = temp_dir.path().join("subdir"); fs::create_dir_all(&subdir).unwrap(); @@ -293,10 +339,10 @@ mod tests { // Switch to subdirectory std::env::set_current_dir(&subdir).unwrap(); - // Should not find project root (not in home directory and no .waylog) + // Should not find project root (not in home directory and no .git) // Note: This test may behave differently in different environments, depending on temp_dir location // If temp_dir is under home directory, find_project_root will stop at home and return None - // If not, it will also return None (because .waylog was not found) + // If not, it will also return None (because .git was not found) let _found_root = find_project_root(); // In test environment, temp_dir is usually not under home, so should return None // But we don't enforce assertion because behavior may vary by environment