Skip to main content

git_iris/companion/
mod.rs

1//! Iris Companion - Ambient awareness for Git workflows
2//!
3//! Provides session tracking, branch memory, and live file watching
4//! to transform Studio into an always-aware development companion.
5
6mod branch_memory;
7mod session;
8mod storage;
9mod watcher;
10
11pub use branch_memory::{BranchMemory, FileFocus};
12pub use session::{FileActivity, SessionState};
13pub use storage::CompanionStorage;
14pub use watcher::{CompanionEvent, FileWatcherService};
15
16use anyhow::Result;
17use std::path::{Path, PathBuf};
18use std::sync::Arc;
19use tokio::sync::mpsc;
20
21/// Main companion service that coordinates all subsystems
22pub struct CompanionService {
23    /// Repository path being watched
24    repo_path: PathBuf,
25    /// Current session state
26    session: Arc<parking_lot::RwLock<SessionState>>,
27    /// Storage backend for persistence
28    storage: CompanionStorage,
29    /// File watcher service (optional - may fail to start)
30    watcher: Option<FileWatcherService>,
31    /// Channel for receiving companion events
32    event_rx: mpsc::UnboundedReceiver<CompanionEvent>,
33    /// Channel sender (held to keep channel alive)
34    _event_tx: mpsc::UnboundedSender<CompanionEvent>,
35}
36
37impl CompanionService {
38    /// Create a new companion service for the given repository
39    ///
40    /// # Errors
41    ///
42    /// Returns an error when companion storage cannot be initialized.
43    pub fn new(repo_path: PathBuf, branch: &str) -> Result<Self> {
44        let (event_tx, event_rx) = mpsc::unbounded_channel();
45        let storage = CompanionStorage::new(&repo_path)?;
46        let session = Arc::new(parking_lot::RwLock::new(load_session(
47            &storage, &repo_path, branch,
48        )));
49        let watcher = start_file_watcher(&repo_path, event_tx.clone());
50
51        Ok(Self {
52            repo_path,
53            session,
54            storage,
55            watcher,
56            event_rx,
57            _event_tx: event_tx,
58        })
59    }
60
61    /// Get the current session state
62    #[must_use]
63    pub fn session(&self) -> &Arc<parking_lot::RwLock<SessionState>> {
64        &self.session
65    }
66
67    /// Load branch memory for the given branch
68    ///
69    /// # Errors
70    ///
71    /// Returns an error when the branch memory cannot be read or parsed.
72    pub fn load_branch_memory(&self, branch: &str) -> Result<Option<BranchMemory>> {
73        self.storage.load_branch_memory(branch)
74    }
75
76    /// Save branch memory
77    ///
78    /// # Errors
79    ///
80    /// Returns an error when the branch memory cannot be serialized or written.
81    pub fn save_branch_memory(&self, memory: &BranchMemory) -> Result<()> {
82        self.storage.save_branch_memory(memory)
83    }
84
85    /// Save current session state
86    ///
87    /// # Errors
88    ///
89    /// Returns an error when the session cannot be serialized or written.
90    pub fn save_session(&self) -> Result<()> {
91        let session = self.session.read();
92        self.storage.save_session(&session)
93    }
94
95    /// Record a file touch (opened/modified)
96    pub fn touch_file(&self, path: PathBuf) {
97        let mut session = self.session.write();
98        session.touch_file(path);
99    }
100
101    /// Record a commit was made
102    pub fn record_commit(&self, hash: String) {
103        let mut session = self.session.write();
104        session.record_commit(hash);
105    }
106
107    /// Try to receive the next companion event (non-blocking)
108    pub fn try_recv_event(&mut self) -> Option<CompanionEvent> {
109        self.event_rx.try_recv().ok()
110    }
111
112    /// Check if file watcher is active
113    #[must_use]
114    pub fn has_watcher(&self) -> bool {
115        self.watcher.is_some()
116    }
117
118    /// Get repository path
119    #[must_use]
120    pub fn repo_path(&self) -> &PathBuf {
121        &self.repo_path
122    }
123}
124
125fn load_session(storage: &CompanionStorage, repo_path: &Path, branch: &str) -> SessionState {
126    match storage.load_session() {
127        Ok(Some(mut session)) if session.branch == branch => {
128            repo_path.clone_into(&mut session.repo_path);
129            session
130        }
131        Ok(Some(session)) => {
132            tracing::info!(
133                "Ignoring session data for branch {} while starting on {}",
134                session.branch,
135                branch
136            );
137            SessionState::new(repo_path.to_path_buf(), branch.to_owned())
138        }
139        Ok(None) => SessionState::new(repo_path.to_path_buf(), branch.to_owned()),
140        Err(e) => {
141            tracing::warn!("Failed to load companion session; starting fresh: {}", e);
142            SessionState::new(repo_path.to_path_buf(), branch.to_owned())
143        }
144    }
145}
146
147fn start_file_watcher(
148    repo_path: &Path,
149    event_tx: mpsc::UnboundedSender<CompanionEvent>,
150) -> Option<FileWatcherService> {
151    match FileWatcherService::new(repo_path, event_tx) {
152        Ok(watcher) => {
153            tracing::info!("Companion file watcher started");
154            Some(watcher)
155        }
156        Err(e) => {
157            tracing::warn!(
158                "Failed to start file watcher: {}. Companion will run without live updates.",
159                e
160            );
161            None
162        }
163    }
164}
165
166impl Drop for CompanionService {
167    fn drop(&mut self) {
168        // Try to save session on shutdown
169        if let Err(e) = self.save_session() {
170            tracing::warn!("Failed to save session on shutdown: {}", e);
171        }
172    }
173}
174
175#[cfg(test)]
176mod tests;