Skip to main content

code_system_graph/
sync.rs

1//! Incremental synchronization orchestration for local workspace indexes.
2
3use std::ffi::{OsStr, OsString};
4use std::path::{Path, PathBuf};
5use std::process::{Command, Stdio};
6use std::time::{Duration, Instant};
7
8use code_system_graph_core::{
9    CodeGraphConfig, CodeGraphProvider, ConfigSource, EffectiveRepositoryConfig, IgnorePolicy, ProviderBudget, ProviderRequest, ProviderStatus
10};
11use code_system_graph_model::RepoId;
12use schemars::JsonSchema;
13use serde::{Deserialize, Serialize};
14use tokio_util::sync::CancellationToken;
15
16use super::{
17    ApplicationError, ScanOverrides, ScanSummary, load_workspace_context, work_database_instance_id
18};
19
20const MAX_PERSISTED_WATCH_TARGET_BYTES: u64 = 8 * 1024 * 1024;
21
22/// One registered repository that participates in synchronization.
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct SyncTarget {
25    /// Repository alias from the workspace manifest.
26    pub alias: String,
27    /// Canonical local checkout path.
28    pub path: PathBuf,
29    /// Effective native discovery policy for this checkout.
30    pub ignore_policy: IgnorePolicy,
31    /// Explicit repository-relative artifacts that must remain observable through exclusions.
32    pub explicit_paths: Vec<PathBuf>,
33}
34
35#[derive(Debug, Serialize, Deserialize)]
36struct PersistedWatchTarget {
37    alias: String,
38    path: PathBuf,
39    configured_excludes: Vec<String>,
40    configured_excludes_source: ConfigSource,
41    include_defaults: Vec<String>,
42    include_defaults_source: ConfigSource,
43    explicit_paths: Vec<PathBuf>,
44}
45
46impl From<&SyncTarget> for PersistedWatchTarget {
47    fn from(target: &SyncTarget) -> Self {
48        Self {
49            alias: target.alias.clone(),
50            path: target.path.clone(),
51            configured_excludes: target.ignore_policy.configured_excludes().to_vec(),
52            configured_excludes_source: target.ignore_policy.configured_excludes_source(),
53            include_defaults: target.ignore_policy.include_defaults().to_vec(),
54            include_defaults_source: target.ignore_policy.include_defaults_source(),
55            explicit_paths: target.explicit_paths.clone(),
56        }
57    }
58}
59
60impl TryFrom<PersistedWatchTarget> for SyncTarget {
61    type Error = ApplicationError;
62
63    fn try_from(target: PersistedWatchTarget) -> Result<Self, Self::Error> {
64        let ignore_policy = IgnorePolicy::new(
65            target.configured_excludes,
66            target.configured_excludes_source,
67            target.include_defaults,
68            target.include_defaults_source,
69        )
70        .map_err(|error| {
71            ApplicationError::Initialization(format!("invalid persisted watch scope: {error}"))
72        })?;
73        Ok(Self {
74            alias: target.alias,
75            path: target.path,
76            ignore_policy,
77            explicit_paths: target.explicit_paths,
78        })
79    }
80}
81
82/// Outcome of synchronizing one repository's local `CodeGraph` index.
83#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
84#[serde(rename_all = "snake_case")]
85pub enum CodeGraphSyncState {
86    /// `codegraph sync` completed successfully.
87    Synchronized,
88    /// The repository has no initialized `.codegraph` index.
89    SkippedNotInitialized,
90    /// The external synchronization command failed.
91    Failed,
92}
93
94/// Bounded result for one repository's local `CodeGraph` index.
95#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
96pub struct CodeGraphRepositorySync {
97    /// Repository alias from the workspace manifest.
98    pub repository: String,
99    /// Synchronization outcome.
100    pub state: CodeGraphSyncState,
101    /// Bounded diagnostic when the index was skipped or failed.
102    pub detail: Option<String>,
103}
104
105/// Aggregate `CodeGraph` synchronization result.
106#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
107pub struct CodeGraphSyncSummary {
108    /// Whether local `CodeGraph` synchronization was requested.
109    pub enabled: bool,
110    /// Number of selected workspace repositories.
111    pub repository_count: usize,
112    /// Number of local indexes synchronized successfully.
113    pub synchronized_count: usize,
114    /// Number of local indexes whose structured status required synchronization.
115    #[serde(default)]
116    pub changed_count: usize,
117    /// Number of initialized local indexes that were already current.
118    #[serde(default)]
119    pub unchanged_count: usize,
120    /// Number of repositories without an initialized local index.
121    pub skipped_count: usize,
122    /// Number of external synchronization failures.
123    pub failed_count: usize,
124    /// Deterministic per-repository outcomes.
125    pub repositories: Vec<CodeGraphRepositorySync>,
126}
127
128/// Observable result of one `csgraph sync` pass.
129#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
130pub struct SyncSummary {
131    /// Output schema version.
132    pub schema_version: u8,
133    /// Resource accounting for the complete supervised sync pass.
134    pub execution: code_system_graph_core::ExecutionSummary,
135    /// Incremental Code System Graph scan result.
136    pub scan: ScanSummary,
137    /// Local per-repository `CodeGraph` index results.
138    pub codegraph: CodeGraphSyncSummary,
139}
140
141/// Resolves and validates the repository checkouts selected for synchronization.
142///
143/// # Errors
144///
145/// Returns [`ApplicationError`] when the manifest, repository selection, or checkout registry is
146/// invalid.
147pub fn workspace_sync_targets(
148    config_path: &Path,
149    overrides: &ScanOverrides,
150) -> Result<Vec<SyncTarget>, ApplicationError> {
151    let context = load_workspace_context(config_path, overrides)?;
152    if let Some(requested) = &overrides.workspace
153        && requested != &context.manifest.name
154    {
155        return Err(ApplicationError::WorkspaceNameMismatch {
156            requested: requested.clone(),
157            manifest: context.manifest.name,
158        });
159    }
160    if let Some(selected) = &overrides.repository
161        && !context
162            .registry
163            .record
164            .repositories
165            .iter()
166            .any(|repository| &repository.alias == selected)
167    {
168        return Err(ApplicationError::UnknownOverrideRepository(
169            selected.clone(),
170        ));
171    }
172
173    context
174        .registry
175        .record
176        .repositories
177        .iter()
178        .filter(|repository| {
179            overrides
180                .repository
181                .as_ref()
182                .is_none_or(|selected| selected == &repository.alias)
183        })
184        .map(|repository| {
185            let path = context
186                .registry
187                .checkout_path(&repository.alias)
188                .ok_or_else(|| ApplicationError::RegistryAliasMissing(repository.alias.clone()))?;
189            let effective = context
190                .repository_configs
191                .get(&repository.alias)
192                .ok_or_else(|| ApplicationError::RegistryAliasMissing(repository.alias.clone()))?;
193            Ok(SyncTarget {
194                alias: repository.alias.clone(),
195                path: path.to_path_buf(),
196                ignore_policy: effective.ignore_policy.clone(),
197                explicit_paths: explicit_watch_paths(effective),
198            })
199        })
200        .collect()
201}
202
203fn explicit_watch_paths(config: &EffectiveRepositoryConfig) -> Vec<PathBuf> {
204    let mut paths = vec![PathBuf::from(".code-system-graph.yaml")];
205    paths.extend(config.openapi.iter().map(PathBuf::from));
206    paths.extend(
207        config
208            .http_consumers
209            .iter()
210            .map(|consumer| PathBuf::from(&consumer.source)),
211    );
212    paths.extend(
213        config
214            .integration_tests
215            .iter()
216            .map(|test| PathBuf::from(&test.path)),
217    );
218    paths.extend(
219        config
220            .implementations
221            .iter()
222            .map(|implementation| PathBuf::from(&implementation.path)),
223    );
224    paths.sort();
225    paths.dedup();
226    paths
227}
228
229/// Synchronizes initialized local `CodeGraph` indexes and then publishes an incremental graph
230/// snapshot.
231///
232/// The external index is optional and best effort: a missing or failed per-repository index is
233/// reported explicitly without preventing the native Code System Graph scan.
234///
235/// # Errors
236///
237/// Returns [`ApplicationError`] when workspace validation or the native incremental scan fails.
238pub fn sync_workspace_with_overrides(
239    config_path: &Path,
240    database_path: &Path,
241    overrides: &ScanOverrides,
242    synchronize_codegraph: bool,
243) -> Result<SyncSummary, ApplicationError> {
244    super::worker::supervise_sync(config_path, database_path, overrides, synchronize_codegraph)
245}
246
247/// Synchronizes through an explicitly selected compatible worker executable.
248///
249/// Embedding applications can pass their own executable after dispatching `__worker-v1` to
250/// [`crate::run_worker_from_stdio`].
251///
252/// # Errors
253///
254/// Returns [`ApplicationError`] when the worker cannot start or synchronization fails.
255pub fn sync_workspace_with_worker_executable(
256    config_path: &Path,
257    database_path: &Path,
258    overrides: &ScanOverrides,
259    synchronize_codegraph: bool,
260    worker_executable: &Path,
261) -> Result<SyncSummary, ApplicationError> {
262    super::worker::supervise_sync_with_executable(
263        config_path,
264        database_path,
265        overrides,
266        synchronize_codegraph,
267        worker_executable,
268    )
269}
270
271#[doc(hidden)]
272pub fn sync_workspace_with_wall_time_cap(
273    config_path: &Path,
274    database_path: &Path,
275    overrides: &ScanOverrides,
276    synchronize_codegraph: bool,
277    wall_time_cap_ms: u64,
278) -> Result<SyncSummary, ApplicationError> {
279    super::worker::supervise_sync_with_wall_time_cap(
280        config_path,
281        database_path,
282        overrides,
283        synchronize_codegraph,
284        wall_time_cap_ms,
285    )
286}
287
288pub(crate) fn sync_workspace_direct(
289    config_path: &Path,
290    database_path: &Path,
291    overrides: &ScanOverrides,
292    synchronize_codegraph: bool,
293) -> Result<SyncSummary, ApplicationError> {
294    let context = load_workspace_context(config_path, overrides)?;
295    let policy = context.execution_policy;
296    let workspace = context.manifest.name;
297    let targets = workspace_sync_targets(config_path, overrides)?;
298    persist_watch_targets(database_path, &workspace, &targets)?;
299    let binary = codegraph_binary(overrides);
300    let codegraph_timeout = Duration::from_millis(policy.max_codegraph_sync_wall_time_ms_per_repo);
301    let codegraph = synchronize_codegraph_targets(
302        &targets,
303        synchronize_codegraph,
304        codegraph_timeout,
305        |target, deadline| run_codegraph_status(&binary, target, deadline),
306        |path, deadline| run_codegraph_sync(&binary, path, deadline),
307    );
308    let mut scan_overrides = overrides.clone();
309    scan_overrides.codegraph = codegraph.synchronized_count > 0;
310    let scan = super::scan_workspace_direct_for_sync(
311        config_path,
312        database_path,
313        &scan_overrides,
314        codegraph.changed_count == 0,
315    )?;
316    Ok(SyncSummary {
317        schema_version: 1,
318        execution: code_system_graph_core::ExecutionSummary::default(),
319        scan,
320        codegraph,
321    })
322}
323
324fn run_codegraph_status(
325    binary: &OsStr,
326    target: &SyncTarget,
327    deadline: Instant,
328) -> Result<ProviderStatus, String> {
329    let binary = binary.to_owned();
330    let alias = target.alias.clone();
331    let project_path = target.path.clone();
332    std::thread::spawn(move || {
333        let timeout = deadline.saturating_duration_since(Instant::now());
334        if timeout.is_zero() {
335            return Err(
336                "CodeGraph status exceeded the repository synchronization deadline".to_owned(),
337            );
338        }
339        let runtime = tokio::runtime::Builder::new_current_thread()
340            .enable_all()
341            .build()
342            .map_err(|error| format!("cannot start CodeGraph status runtime: {error}"))?;
343        let provider = CodeGraphProvider::new(CodeGraphConfig {
344            binary,
345            max_concurrent_processes: 1,
346            ..CodeGraphConfig::default()
347        })
348        .map_err(|error| format!("cannot configure CodeGraph status provider: {error}"))?;
349        runtime
350            .block_on(provider.index_status(ProviderRequest {
351                repo_id: RepoId::new(format!("sync:{alias}")),
352                project_path,
353                budget: ProviderBudget {
354                    timeout,
355                    max_output_bytes: 256 * 1024,
356                    max_items: 1,
357                },
358                cancellation: CancellationToken::new(),
359            }))
360            .map_err(|error| format!("cannot inspect CodeGraph status: {error}"))
361    })
362    .join()
363    .map_err(|_| "CodeGraph status worker terminated unexpectedly".to_owned())?
364}
365
366fn persist_watch_targets(
367    database_path: &Path,
368    workspace: &str,
369    targets: &[SyncTarget],
370) -> Result<(), ApplicationError> {
371    let mut encoded = Vec::with_capacity(targets.len());
372    for target in targets {
373        let payload = serde_json::to_vec(&PersistedWatchTarget::from(target))
374            .map_err(|error| ApplicationError::Initialization(error.to_string()))?;
375        if u64::try_from(payload.len()).unwrap_or(u64::MAX) > MAX_PERSISTED_WATCH_TARGET_BYTES {
376            return Err(ApplicationError::Initialization(
377                "persisted watch target exceeded its protocol bound".to_owned(),
378            ));
379        }
380        encoded.push((target.alias.clone(), payload));
381    }
382    let database_instance_id =
383        code_system_graph_store_sqlite::SqliteStore::open(database_path)?.database_instance_id()?;
384    super::work_state::WorkState::open(database_path, &database_instance_id)
385        .and_then(|mut state| state.replace_watch_scope(workspace, &encoded))
386        .map_err(ApplicationError::Initialization)
387}
388
389#[doc(hidden)]
390pub fn load_persisted_watch_targets(
391    database_path: &Path,
392    workspace: &str,
393) -> Result<Vec<SyncTarget>, ApplicationError> {
394    let database_instance_id = work_database_instance_id(database_path)?;
395    let state = super::work_state::WorkState::open(database_path, &database_instance_id)
396        .map_err(ApplicationError::Initialization)?;
397    state
398        .load_watch_scope(workspace, MAX_PERSISTED_WATCH_TARGET_BYTES)
399        .map_err(ApplicationError::Initialization)?
400        .into_iter()
401        .map(|encoded| {
402            serde_json::from_slice::<PersistedWatchTarget>(&encoded)
403                .map_err(|error| ApplicationError::Initialization(error.to_string()))?
404                .try_into()
405        })
406        .collect()
407}
408
409fn codegraph_binary(overrides: &ScanOverrides) -> OsString {
410    overrides
411        .codegraph_binary
412        .as_ref()
413        .map(|path| path.as_os_str().to_owned())
414        .or_else(|| {
415            std::env::var_os("CODE_SYSTEM_GRAPH_CODEGRAPH_BINARY").filter(|value| !value.is_empty())
416        })
417        .unwrap_or_else(|| OsString::from("codegraph"))
418}
419
420fn run_codegraph_sync(
421    binary: &OsStr,
422    project_path: &Path,
423    deadline: Instant,
424) -> Result<(), String> {
425    if Instant::now() >= deadline {
426        return Err("codegraph sync exceeded the repository synchronization deadline".to_owned());
427    }
428    let mut command = Command::new(binary);
429    command
430        .arg("sync")
431        .arg("--quiet")
432        .arg(project_path)
433        .current_dir(project_path)
434        .stdin(Stdio::null())
435        .stdout(Stdio::null())
436        .stderr(Stdio::null());
437    configure_codegraph_process_group(&mut command);
438    let mut child = command
439        .spawn()
440        .map_err(|error| format!("failed to start codegraph sync: {error}"))?;
441    let status = loop {
442        if let Some(status) = child
443            .try_wait()
444            .map_err(|error| format!("failed to wait for codegraph sync: {error}"))?
445        {
446            break status;
447        }
448        if Instant::now() >= deadline {
449            super::worker::terminate_process_tree(child.id());
450            terminate_codegraph_process_group(&mut child);
451            return Err(
452                "codegraph sync exceeded the repository synchronization deadline and was terminated"
453                    .to_owned(),
454            );
455        }
456        std::thread::sleep(Duration::from_millis(50));
457    };
458    cleanup_exited_codegraph_process_group(child.id());
459    if status.success() {
460        return Ok(());
461    }
462    Err(format!("codegraph sync exited with {status}"))
463}
464
465#[cfg(unix)]
466fn configure_codegraph_process_group(command: &mut Command) {
467    use std::os::unix::process::CommandExt;
468    command.process_group(0);
469}
470
471#[cfg(not(unix))]
472fn configure_codegraph_process_group(_command: &mut Command) {}
473
474#[cfg(unix)]
475fn terminate_codegraph_process_group(child: &mut std::process::Child) {
476    if let Ok(process_group_id) = i32::try_from(child.id()) {
477        let process_group_id = nix::unistd::Pid::from_raw(process_group_id);
478        let _ = nix::sys::signal::killpg(process_group_id, nix::sys::signal::Signal::SIGTERM);
479        std::thread::sleep(Duration::from_millis(100));
480        let _ = nix::sys::signal::killpg(process_group_id, nix::sys::signal::Signal::SIGKILL);
481    }
482    let _ = child.wait();
483}
484
485#[cfg(not(unix))]
486fn terminate_codegraph_process_group(child: &mut std::process::Child) {
487    let _ = child.kill();
488    let _ = child.wait();
489}
490
491#[cfg(unix)]
492fn cleanup_exited_codegraph_process_group(process_id: u32) {
493    if let Ok(process_group_id) = i32::try_from(process_id) {
494        let _ = nix::sys::signal::killpg(
495            nix::unistd::Pid::from_raw(process_group_id),
496            nix::sys::signal::Signal::SIGKILL,
497        );
498    }
499}
500
501#[cfg(not(unix))]
502fn cleanup_exited_codegraph_process_group(process_id: u32) {
503    super::worker::terminate_process_tree(process_id);
504}
505
506#[derive(Debug)]
507struct CodeGraphSyncObservation {
508    state: CodeGraphSyncState,
509    detail: Option<String>,
510    index_changed: bool,
511}
512
513fn synchronize_codegraph_targets<I, S>(
514    targets: &[SyncTarget],
515    enabled: bool,
516    timeout: Duration,
517    mut inspect: I,
518    mut synchronize: S,
519) -> CodeGraphSyncSummary
520where
521    I: FnMut(&SyncTarget, Instant) -> Result<ProviderStatus, String>,
522    S: FnMut(&Path, Instant) -> Result<(), String>,
523{
524    let mut observations = Vec::with_capacity(targets.len());
525    let mut repositories = Vec::with_capacity(targets.len());
526    if enabled {
527        for target in targets {
528            let deadline = Instant::now() + timeout;
529            let observation = if target.path.join(".codegraph").is_dir() {
530                match inspect(target, deadline) {
531                    Ok(ProviderStatus::Available) => CodeGraphSyncObservation {
532                        state: CodeGraphSyncState::Synchronized,
533                        detail: None,
534                        index_changed: false,
535                    },
536                    Ok(ProviderStatus::Stale) => match synchronize(&target.path, deadline) {
537                        Ok(()) => CodeGraphSyncObservation {
538                            state: CodeGraphSyncState::Synchronized,
539                            detail: None,
540                            index_changed: true,
541                        },
542                        Err(detail) => failed_sync_observation(&detail),
543                    },
544                    Ok(ProviderStatus::IndexMissing) => CodeGraphSyncObservation {
545                        state: CodeGraphSyncState::SkippedNotInitialized,
546                        detail: Some("local CodeGraph index is not initialized".to_owned()),
547                        index_changed: false,
548                    },
549                    Ok(status) => failed_sync_observation(&format!(
550                        "CodeGraph status is not usable for synchronization: {status:?}"
551                    )),
552                    Err(detail) => failed_sync_observation(&detail),
553                }
554            } else {
555                CodeGraphSyncObservation {
556                    state: CodeGraphSyncState::SkippedNotInitialized,
557                    detail: Some("local CodeGraph index is not initialized".to_owned()),
558                    index_changed: false,
559                }
560            };
561            repositories.push(CodeGraphRepositorySync {
562                repository: target.alias.clone(),
563                state: observation.state,
564                detail: observation.detail.clone(),
565            });
566            observations.push(observation);
567            super::worker::report_progress(code_system_graph_core::JobPhase::CodeGraphSync, 1);
568        }
569    }
570    repositories.sort_by(|left, right| left.repository.cmp(&right.repository));
571    let synchronized_count = repositories
572        .iter()
573        .filter(|item| item.state == CodeGraphSyncState::Synchronized)
574        .count();
575    let changed_count = observations
576        .iter()
577        .filter(|item| item.state == CodeGraphSyncState::Synchronized && item.index_changed)
578        .count();
579    let unchanged_count = observations
580        .iter()
581        .filter(|item| item.state == CodeGraphSyncState::Synchronized && !item.index_changed)
582        .count();
583    let skipped_count = repositories
584        .iter()
585        .filter(|item| item.state == CodeGraphSyncState::SkippedNotInitialized)
586        .count();
587    let failed_count = repositories
588        .iter()
589        .filter(|item| item.state == CodeGraphSyncState::Failed)
590        .count();
591    CodeGraphSyncSummary {
592        enabled,
593        repository_count: targets.len(),
594        synchronized_count,
595        changed_count,
596        unchanged_count,
597        skipped_count,
598        failed_count,
599        repositories,
600    }
601}
602
603fn failed_sync_observation(detail: &str) -> CodeGraphSyncObservation {
604    CodeGraphSyncObservation {
605        state: CodeGraphSyncState::Failed,
606        detail: Some(bounded_detail(detail)),
607        index_changed: false,
608    }
609}
610
611fn bounded_detail(detail: &str) -> String {
612    const MAX_CHARS: usize = 512;
613    let normalized = detail.split_whitespace().collect::<Vec<_>>().join(" ");
614    if normalized.chars().count() <= MAX_CHARS {
615        return normalized;
616    }
617    let mut bounded = normalized.chars().take(MAX_CHARS).collect::<String>();
618    bounded.push_str("...");
619    bounded
620}
621
622#[cfg(test)]
623mod tests {
624    use std::cell::RefCell;
625
626    use super::*;
627
628    fn target(alias: &str, path: PathBuf) -> SyncTarget {
629        SyncTarget {
630            alias: alias.to_owned(),
631            path,
632            ignore_policy: IgnorePolicy::new(
633                Vec::new(),
634                code_system_graph_core::ConfigSource::Default,
635                Vec::new(),
636                code_system_graph_core::ConfigSource::Default,
637            )
638            .expect("built-in ignore policy"),
639            explicit_paths: Vec::new(),
640        }
641    }
642
643    #[test]
644    fn persisted_watch_scope_should_preserve_every_alias_policy() -> anyhow::Result<()> {
645        let temporary = tempfile::tempdir()?;
646        let database = temporary.path().join("graph.db");
647        let shared = temporary.path().join("shared");
648        std::fs::create_dir(&shared)?;
649        let mut restrictive = target("a-restrictive", shared.clone());
650        restrictive.ignore_policy = IgnorePolicy::new(
651            vec!["generated/**".to_owned()],
652            ConfigSource::WorkspaceManifest,
653            Vec::new(),
654            ConfigSource::Default,
655        )?;
656        let permissive = target("b-permissive", shared);
657        let expected = vec![restrictive, permissive];
658
659        persist_watch_targets(&database, "workspace", &expected)?;
660        let actual = load_persisted_watch_targets(&database, "workspace")?;
661
662        assert_eq!(actual, expected);
663        Ok(())
664    }
665
666    #[test]
667    fn synchronization_should_skip_uninitialized_indexes_and_bound_failures() -> anyhow::Result<()>
668    {
669        let temporary = tempfile::tempdir()?;
670        let initialized = temporary.path().join("initialized");
671        let absent = temporary.path().join("absent");
672        std::fs::create_dir_all(initialized.join(".codegraph"))?;
673        std::fs::create_dir(&absent)?;
674        let called = RefCell::new(Vec::new());
675        let targets = vec![target("zeta", initialized.clone()), target("alpha", absent)];
676
677        let report = synchronize_codegraph_targets(
678            &targets,
679            true,
680            Duration::from_secs(1),
681            |target, _| {
682                called.borrow_mut().push(target.path.clone());
683                Err("failure ".repeat(600))
684            },
685            |_, _| panic!("failed status inspection must not synchronize"),
686        );
687
688        assert_eq!(called.into_inner(), vec![initialized]);
689        assert_eq!(report.repository_count, 2);
690        assert_eq!(report.synchronized_count, 0);
691        assert_eq!(report.skipped_count, 1);
692        assert_eq!(report.failed_count, 1);
693        assert_eq!(report.repositories[0].repository, "alpha");
694        assert_eq!(
695            report.repositories[0].state,
696            CodeGraphSyncState::SkippedNotInitialized
697        );
698        assert!(
699            report.repositories[1]
700                .detail
701                .as_deref()
702                .is_some_and(|detail| detail.chars().count() <= 515)
703        );
704        Ok(())
705    }
706
707    #[test]
708    fn disabled_codegraph_sync_should_not_invoke_runner() {
709        let target = target("repo", PathBuf::from("repo"));
710        let report = synchronize_codegraph_targets(
711            &[target],
712            false,
713            Duration::from_secs(1),
714            |_, _| panic!("disabled synchronization must not inspect CodeGraph"),
715            |_, _| panic!("disabled synchronization must not invoke CodeGraph"),
716        );
717        assert!(!report.enabled);
718        assert_eq!(report.repository_count, 1);
719        assert!(report.repositories.is_empty());
720    }
721
722    #[test]
723    fn synchronization_should_distinguish_changed_and_current_indexes() -> anyhow::Result<()> {
724        let temporary = tempfile::tempdir()?;
725        let current = temporary.path().join("current");
726        let stale = temporary.path().join("stale");
727        std::fs::create_dir_all(current.join(".codegraph"))?;
728        std::fs::create_dir_all(stale.join(".codegraph"))?;
729        let synchronized = RefCell::new(Vec::new());
730        let targets = vec![target("current", current), target("stale", stale.clone())];
731
732        let report = synchronize_codegraph_targets(
733            &targets,
734            true,
735            Duration::from_secs(1),
736            |target, _| {
737                if target.alias == "stale" {
738                    Ok(ProviderStatus::Stale)
739                } else {
740                    Ok(ProviderStatus::Available)
741                }
742            },
743            |path, _| {
744                synchronized.borrow_mut().push(path.to_path_buf());
745                Ok(())
746            },
747        );
748
749        assert_eq!(synchronized.into_inner(), vec![stale]);
750        assert_eq!(report.synchronized_count, 2);
751        assert_eq!(report.changed_count, 1);
752        assert_eq!(report.unchanged_count, 1);
753        Ok(())
754    }
755
756    #[test]
757    fn synchronization_should_share_one_deadline_between_status_and_sync() -> anyhow::Result<()> {
758        let temporary = tempfile::tempdir()?;
759        let repository = temporary.path().join("stale");
760        std::fs::create_dir_all(repository.join(".codegraph"))?;
761        let inspected_deadline = RefCell::new(None);
762        let synchronized_deadline = RefCell::new(None);
763
764        let report = synchronize_codegraph_targets(
765            &[target("stale", repository)],
766            true,
767            Duration::from_secs(1),
768            |_, deadline| {
769                inspected_deadline.replace(Some(deadline));
770                Ok(ProviderStatus::Stale)
771            },
772            |_, deadline| {
773                synchronized_deadline.replace(Some(deadline));
774                Ok(())
775            },
776        );
777
778        assert_eq!(report.changed_count, 1);
779        assert_eq!(
780            inspected_deadline.into_inner(),
781            synchronized_deadline.into_inner()
782        );
783        Ok(())
784    }
785
786    #[cfg(unix)]
787    #[test]
788    fn codegraph_timeout_should_terminate_its_descendant_process() -> anyhow::Result<()> {
789        use std::os::unix::fs::PermissionsExt;
790
791        let temporary = tempfile::tempdir()?;
792        let script = temporary.path().join("codegraph-test");
793        let descendant_pid = temporary.path().join("descendant.pid");
794        std::fs::write(
795            &script,
796            format!(
797                "#!/bin/sh\nsleep 30 &\nprintf '%s' \"$!\" > '{}'\nwait\n",
798                descendant_pid.display()
799            ),
800        )?;
801        std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o700))?;
802
803        let error = run_codegraph_sync(
804            script.as_os_str(),
805            temporary.path(),
806            Instant::now() + Duration::from_secs(2),
807        )
808        .expect_err("test process must time out");
809        assert!(
810            error.contains("was terminated"),
811            "unexpected error: {error}"
812        );
813        let pid = std::fs::read_to_string(&descendant_pid)?.parse::<i32>()?;
814        for _ in 0..20 {
815            if nix::sys::signal::kill(nix::unistd::Pid::from_raw(pid), None).is_err() {
816                return Ok(());
817            }
818            std::thread::sleep(Duration::from_millis(10));
819        }
820        anyhow::bail!("CodeGraph descendant {pid} survived timeout")
821    }
822
823    #[cfg(unix)]
824    #[test]
825    fn successful_codegraph_sync_should_terminate_surviving_descendants() -> anyhow::Result<()> {
826        use std::os::unix::fs::PermissionsExt;
827
828        let temporary = tempfile::tempdir()?;
829        let script = temporary.path().join("codegraph-test");
830        let descendant_pid = temporary.path().join("descendant.pid");
831        std::fs::write(
832            &script,
833            format!(
834                "#!/bin/sh\nsh -c 'trap \"\" TERM; sleep 30' &\nprintf '%s' \"$!\" > '{}'\nexit 0\n",
835                descendant_pid.display()
836            ),
837        )?;
838        std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o700))?;
839
840        run_codegraph_sync(
841            script.as_os_str(),
842            temporary.path(),
843            Instant::now() + Duration::from_secs(2),
844        )
845        .map_err(anyhow::Error::msg)?;
846        let pid = std::fs::read_to_string(&descendant_pid)?.parse::<i32>()?;
847        for _ in 0..20 {
848            if nix::sys::signal::kill(nix::unistd::Pid::from_raw(pid), None).is_err() {
849                return Ok(());
850            }
851            std::thread::sleep(Duration::from_millis(10));
852        }
853        anyhow::bail!("CodeGraph descendant {pid} survived successful sync")
854    }
855}