mermaid_cli/models/
lazy_context.rs

1use anyhow::Result;
2use rustc_hash::FxHashMap;
3use std::path::{Path, PathBuf};
4use std::sync::atomic::{AtomicUsize, Ordering};
5use std::sync::{Arc, Mutex};
6use tokio::sync::RwLock;
7
8/// A lazily-loaded project context that loads files on demand
9#[derive(Debug, Clone)]
10pub struct LazyProjectContext {
11    /// Root path of the project
12    pub root_path: String,
13    /// Detected project type (Rust, Python, JavaScript, etc.)
14    pub project_type: String,
15    /// List of all file paths (loaded immediately)
16    pub file_paths: Arc<Vec<PathBuf>>,
17    /// Lazily loaded file contents (using FxHashMap for performance)
18    pub files: Arc<RwLock<FxHashMap<String, String>>>,
19    /// Running token count (updated as files are loaded)
20    pub token_count: Arc<AtomicUsize>,
21    /// Files that have been requested for loading
22    pub loading_queue: Arc<Mutex<Vec<PathBuf>>>,
23    /// Cache manager for persistent caching
24    pub cache: Option<Arc<crate::cache::CacheManager>>,
25}
26
27impl LazyProjectContext {
28    /// Create a new lazy project context with just file paths
29    pub fn new(root_path: String, file_paths: Vec<PathBuf>) -> Self {
30        let cache = crate::cache::CacheManager::new().ok().map(Arc::new);
31
32        Self {
33            root_path: root_path.clone(),
34            project_type: detect_project_type(&root_path),
35            file_paths: Arc::new(file_paths),
36            files: Arc::new(RwLock::new(FxHashMap::default())),
37            token_count: Arc::new(AtomicUsize::new(0)),
38            loading_queue: Arc::new(Mutex::new(Vec::new())),
39            cache,
40        }
41    }
42
43    /// Get a file's content, loading it if necessary
44    pub async fn get_file(&self, path: &str) -> Result<Option<String>> {
45        // Check if already loaded
46        {
47            let files = self.files.read().await;
48            if let Some(content) = files.get(path) {
49                return Ok(Some(content.clone()));
50            }
51        }
52
53        // Not loaded, need to load it
54        let full_path = if path.starts_with(&self.root_path) {
55            PathBuf::from(path)
56        } else {
57            PathBuf::from(&self.root_path).join(path)
58        };
59
60        // Load the file
61        if full_path.exists() {
62            let content = tokio::fs::read_to_string(&full_path).await?;
63
64            // Count tokens
65            if let Some(ref cache) = self.cache {
66                if let Ok(tokens) = cache.get_or_compute_tokens(&full_path, &content, "cl100k_base")
67                {
68                    self.token_count.fetch_add(tokens, Ordering::Relaxed);
69                }
70            }
71
72            // Store in memory
73            let mut files = self.files.write().await;
74            files.insert(path.to_string(), content.clone());
75
76            Ok(Some(content))
77        } else {
78            Ok(None)
79        }
80    }
81
82    /// Load a batch of files in the background
83    pub async fn load_files_batch(&self, paths: Vec<String>) -> Result<()> {
84        use futures::future::join_all;
85
86        let futures = paths.into_iter().map(|path| {
87            let self_clone = self.clone();
88            async move {
89                let _ = self_clone.get_file(&path).await;
90            }
91        });
92
93        join_all(futures).await;
94        Ok(())
95    }
96
97    /// Get the list of all file paths (instant)
98    pub fn get_file_list(&self) -> Vec<String> {
99        self.file_paths
100            .iter()
101            .filter_map(|p| {
102                p.strip_prefix(&self.root_path)
103                    .ok()
104                    .and_then(|p| p.to_str())
105                    .map(|s| s.to_string())
106            })
107            .collect()
108    }
109
110    /// Get current loaded file count
111    pub async fn loaded_file_count(&self) -> usize {
112        self.files.read().await.len()
113    }
114
115    /// Get total file count
116    pub fn total_file_count(&self) -> usize {
117        self.file_paths.len()
118    }
119
120    /// Check if all files are loaded
121    pub async fn is_fully_loaded(&self) -> bool {
122        self.loaded_file_count().await >= self.total_file_count()
123    }
124
125    /// Convert to regular ProjectContext (for compatibility)
126    pub async fn to_project_context(&self) -> crate::models::ProjectContext {
127        let files = self.files.read().await;
128        let mut context = crate::models::ProjectContext::new(self.root_path.clone());
129        context.project_type = Some(self.project_type.clone());
130        context.token_count = self.token_count.load(Ordering::Relaxed);
131
132        for (path, content) in files.iter() {
133            context.add_file(path.clone(), content.clone());
134        }
135
136        context
137    }
138}
139
140/// Detect project type from root path
141fn detect_project_type(root_path: &str) -> String {
142    let path = Path::new(root_path);
143
144    // Check for various project files
145    if path.join("Cargo.toml").exists() {
146        "Rust".to_string()
147    } else if path.join("package.json").exists() {
148        "JavaScript/TypeScript".to_string()
149    } else if path.join("requirements.txt").exists() || path.join("setup.py").exists() {
150        "Python".to_string()
151    } else if path.join("go.mod").exists() {
152        "Go".to_string()
153    } else if path.join("pom.xml").exists() || path.join("build.gradle").exists() {
154        "Java".to_string()
155    } else if path.join("*.csproj").exists() || path.join("*.sln").exists() {
156        "C#/.NET".to_string()
157    } else if path.join("Gemfile").exists() {
158        "Ruby".to_string()
159    } else if path.join("composer.json").exists() {
160        "PHP".to_string()
161    } else {
162        "Unknown".to_string()
163    }
164}
165
166/// Priority files to load first for better UX
167pub fn get_priority_files(root_path: &str) -> Vec<String> {
168    vec![
169        "README.md",
170        "readme.md",
171        "README.rst",
172        "README.txt",
173        "CLAUDE.md", // Mermaid's own instructions file
174        "Cargo.toml",
175        "package.json",
176        "pyproject.toml",
177        "go.mod",
178        ".gitignore",
179        "LICENSE",
180    ]
181    .into_iter()
182    .filter_map(|f| {
183        let path = Path::new(root_path).join(f);
184        if path.exists() {
185            Some(f.to_string())
186        } else {
187            None
188        }
189    })
190    .collect()
191}