Skip to main content

coding_agent_search/sources/
mod.rs

1//! Remote sources management for cass.
2//!
3//! This module provides functionality for configuring and syncing agent session
4//! data from remote machines via SSH. It enables cass to search across conversation
5//! history from multiple machines.
6//!
7//! # Architecture
8//!
9//! - **config**: Configuration types for defining remote sources
10//! - **provenance**: Types for tracking conversation origins
11//! - **sync**: Sync engine for pulling sessions from remotes via rsync/SSH
12//! - **status** (future): Sync status tracking
13//!
14//! # Configuration
15//!
16//! Sources are configured in `~/.config/cass/sources.toml`:
17//!
18//! ```toml
19//! [[sources]]
20//! name = "laptop"
21//! type = "ssh"
22//! host = "user@laptop.local"
23//! paths = ["~/.claude/projects", "~/.cursor"]
24//! ```
25//!
26//! # Provenance
27//!
28//! Each conversation tracks where it came from via [`provenance::Origin`]:
29//!
30//! ```rust,ignore
31//! use coding_agent_search::sources::provenance::{Origin, SourceKind};
32//!
33//! // Local conversation
34//! let local = Origin::local();
35//!
36//! // Remote conversation
37//! let remote = Origin::remote("work-laptop");
38//! ```
39//!
40//! # Syncing
41//!
42//! The sync engine uses rsync over SSH for efficient delta transfers:
43//!
44//! ```rust,ignore
45//! use coding_agent_search::sources::sync::SyncEngine;
46//! use coding_agent_search::sources::config::SourcesConfig;
47//!
48//! let config = SourcesConfig::load()?;
49//! let engine = SyncEngine::new(&data_dir);
50//!
51//! for source in config.remote_sources() {
52//!     let report = engine.sync_source(source)?;
53//!     println!("Synced {}: {} files", source.name, report.total_files());
54//! }
55//! ```
56//!
57//! # Usage
58//!
59//! ```rust,ignore
60//! use coding_agent_search::sources::config::SourcesConfig;
61//!
62//! // Load configuration
63//! let config = SourcesConfig::load()?;
64//!
65//! // Iterate remote sources
66//! for source in config.remote_sources() {
67//!     println!("Source: {} ({})", source.name, source.host.as_deref().unwrap_or("-"));
68//! }
69//! ```
70
71pub mod config;
72pub mod index;
73pub mod install;
74pub mod interactive;
75pub mod probe;
76pub mod provenance;
77pub mod setup;
78pub mod sync;
79
80use std::io::Read as IoRead;
81use std::process::{Child, Output};
82use std::sync::mpsc::{self, Receiver, RecvTimeoutError};
83use std::time::{Duration, Instant};
84
85use wait_timeout::ChildExt;
86
87/// Canonical SSH stderr marker for host-key verification failures.
88pub(crate) const HOST_KEY_VERIFICATION_FAILED: &str = "Host key verification failed";
89
90/// Build strict SSH CLI tokens with consistent trust policy.
91///
92/// The returned vector contains full `ssh` argument tokens:
93/// `-o BatchMode=yes -o ConnectTimeout=<secs> -o StrictHostKeyChecking=yes`.
94pub(crate) fn strict_ssh_cli_tokens(connect_timeout_secs: u64) -> Vec<String> {
95    vec![
96        "-o".to_string(),
97        "BatchMode=yes".to_string(),
98        "-o".to_string(),
99        format!("ConnectTimeout={connect_timeout_secs}"),
100        "-o".to_string(),
101        "ServerAliveInterval=15".to_string(),
102        "-o".to_string(),
103        "ServerAliveCountMax=3".to_string(),
104        "-o".to_string(),
105        "StrictHostKeyChecking=yes".to_string(),
106    ]
107}
108
109/// Build strict SSH command string for tools that require a single shell fragment.
110pub(crate) fn strict_ssh_command_for_rsync(connect_timeout_secs: u64) -> String {
111    format!(
112        "ssh -o BatchMode=yes -o ConnectTimeout={connect_timeout_secs} -o ServerAliveInterval=15 -o ServerAliveCountMax=3 -o StrictHostKeyChecking=yes"
113    )
114}
115
116fn drain_child_pipe<R>(mut pipe: R) -> Receiver<std::io::Result<Vec<u8>>>
117where
118    R: IoRead + Send + 'static,
119{
120    let (sender, receiver) = mpsc::channel();
121    std::thread::spawn(move || {
122        let mut output = Vec::new();
123        let result = pipe.read_to_end(&mut output).map(|_| output);
124        let _ = sender.send(result);
125    });
126    receiver
127}
128
129fn finish_child_pipe(
130    pipe_reader: Option<Receiver<std::io::Result<Vec<u8>>>>,
131    deadline: Instant,
132) -> std::io::Result<Option<Vec<u8>>> {
133    match pipe_reader {
134        Some(reader) => {
135            let remaining = deadline
136                .checked_duration_since(Instant::now())
137                .unwrap_or(Duration::ZERO);
138            match reader.recv_timeout(remaining) {
139                Ok(result) => result.map(Some),
140                Err(RecvTimeoutError::Timeout) => Ok(None),
141                Err(RecvTimeoutError::Disconnected) => Err(std::io::Error::new(
142                    std::io::ErrorKind::BrokenPipe,
143                    "child pipe reader disconnected before sending output",
144                )),
145            }
146        }
147        None => Ok(Some(Vec::new())),
148    }
149}
150
151/// Wait for a child process while draining stdout/stderr without letting either
152/// process execution or pipe collection outlive the same wall-clock deadline.
153pub(crate) fn wait_for_child_output_with_timeout(
154    mut child: Child,
155    timeout: Duration,
156) -> std::io::Result<Option<Output>> {
157    let timeout = if timeout.is_zero() {
158        Duration::from_secs(1)
159    } else {
160        timeout
161    };
162    let start = Instant::now();
163    let deadline = start.checked_add(timeout).unwrap_or(start);
164    let stdout_reader = child.stdout.take().map(drain_child_pipe);
165    let stderr_reader = child.stderr.take().map(drain_child_pipe);
166
167    match child.wait_timeout(timeout)? {
168        Some(status) => {
169            let Some(stdout) = finish_child_pipe(stdout_reader, deadline)? else {
170                return Ok(None);
171            };
172            let Some(stderr) = finish_child_pipe(stderr_reader, deadline)? else {
173                return Ok(None);
174            };
175            Ok(Some(Output {
176                status,
177                stdout,
178                stderr,
179            }))
180        }
181        None => {
182            let _ = child.kill();
183            let _ = child.wait();
184            Ok(None)
185        }
186    }
187}
188
189/// Whether stderr indicates SSH host-key verification failure.
190pub(crate) fn is_host_key_verification_failure(stderr: &str) -> bool {
191    stderr.contains(HOST_KEY_VERIFICATION_FAILED)
192}
193
194/// Standard user-facing error for host-key verification failures.
195pub(crate) fn host_key_verification_error(host: &str) -> String {
196    format!(
197        "Host key verification failed for {host} (add/verify host key in ~/.ssh/known_hosts first)"
198    )
199}
200
201// Re-export commonly used config types
202pub use config::{
203    BackupInfo, ConfigError, ConfigPreview, DiscoveredHost, MergeResult, PathMapping, Platform,
204    SkipReason, SourceConfigGenerator, SourceDefinition, SourcesConfig, SyncSchedule,
205    discover_ssh_hosts, get_preset_paths,
206};
207
208// Re-export commonly used provenance types
209pub use provenance::{LOCAL_SOURCE_ID, Origin, Source, SourceFilter, SourceKind};
210
211// Re-export commonly used sync types
212pub use sync::{
213    PathSyncResult, SourceHealthKind, SourceSyncAction, SourceSyncDecision, SourceSyncInfo,
214    SyncEngine, SyncError, SyncMethod, SyncReport, SyncResult, SyncStatus,
215};
216
217// Re-export commonly used probe types
218pub use probe::{
219    CassStatus, DetectedAgent, HostProbeResult, ProbeCache, ResourceInfo, SystemInfo, probe_host,
220    probe_hosts_parallel,
221};
222
223// Re-export commonly used install types
224pub use install::{
225    InstallError, InstallMethod, InstallProgress, InstallResult, InstallStage, RemoteInstaller,
226};
227
228// Re-export commonly used index types
229pub use index::{IndexError, IndexProgress, IndexResult, IndexStage, RemoteIndexer};
230
231// Re-export commonly used interactive types
232pub use interactive::{
233    CassStatusDisplay, HostDisplayInfo, HostSelectionResult, HostSelector, HostState,
234    InteractiveError, confirm_action, confirm_with_details, probe_to_display_info,
235    run_host_selection,
236};
237
238// Re-export commonly used setup types
239pub use setup::{SetupError, SetupOptions, SetupResult, SetupState, run_setup};