syncdoc_core/
config.rs

1use crate::path_utils::find_manifest_dir;
2use crate::syncdoc_debug;
3use ropey::Rope;
4use std::fs;
5use std::path::{Path, PathBuf};
6use textum::{Boundary, BoundaryMode, Snippet, Target};
7
8/// Get a specified attribute from the current crate's Cargo.toml, relative to the source file
9fn get_attribute_from_cargo_toml(
10    cargo_toml_path: &str,
11    attribute: &str,
12) -> Result<Option<String>, Box<dyn std::error::Error>> {
13    let content = fs::read_to_string(cargo_toml_path)?;
14    let rope = Rope::from_str(&content);
15
16    // Try to find the section text
17    let section_text = if let Ok(resolution) = (Snippet::Between {
18        start: Boundary::new(
19            Target::Literal("[package.metadata.syncdoc]".to_string()),
20            BoundaryMode::Exclude,
21        ),
22        end: Boundary::new(Target::Literal("[".to_string()), BoundaryMode::Exclude),
23    })
24    .resolve(&rope)
25    {
26        rope.slice(resolution.start..resolution.end).to_string()
27    } else {
28        let snippet = Snippet::From(Boundary::new(
29            Target::Literal("[package.metadata.syncdoc]".to_string()),
30            BoundaryMode::Exclude,
31        ));
32        match snippet.resolve(&rope) {
33            Ok(resolution) => rope.slice(resolution.start..resolution.end).to_string(),
34            Err(_) => return Ok(None), // No syncdoc section, return None
35        }
36    };
37
38    // Parse the specified attribute's value
39    for line in section_text.lines() {
40        let line = line.trim();
41        if line.starts_with(attribute) {
42            if let Some(value) = line.split('=').nth(1) {
43                let cleaned = value.trim().trim_matches('"').to_string();
44                return Ok(Some(cleaned));
45            }
46        }
47    }
48
49    Ok(None) // Attribute not found, return None
50}
51
52/// Resolve a source file path to an absolute path, handling both absolute and relative paths
53fn resolve_source_path(source_file: &str) -> Result<PathBuf, Box<dyn std::error::Error>> {
54    let source_path = Path::new(source_file);
55
56    // Make it absolute if it's not already
57    let source_path = if source_path.is_absolute() {
58        source_path.to_path_buf()
59    } else {
60        std::env::current_dir()?.join(source_path)
61    };
62
63    Ok(source_path)
64}
65
66/// Get the cfg-attr from the current crate's Cargo.toml, relative to the source file
67pub fn get_cfg_attr(source_file: &str) -> Result<Option<String>, Box<dyn std::error::Error>> {
68    let source_path = resolve_source_path(source_file)?;
69    let source_dir = source_path
70        .parent()
71        .ok_or("Source file has no parent directory")?;
72
73    let manifest_dir = find_manifest_dir(source_dir).ok_or("Could not find Cargo.toml")?;
74
75    let cargo_toml_path = manifest_dir.join("Cargo.toml");
76    get_attribute_from_cargo_toml(cargo_toml_path.to_str().unwrap(), "cfg-attr")
77}
78
79/// Get the docs-path from the current crate's Cargo.toml, relative to the source file
80pub fn get_docs_path(source_file: &str) -> Result<String, Box<dyn std::error::Error>> {
81    syncdoc_debug!("get_docs_path called:");
82    syncdoc_debug!("  source_file: {}", source_file);
83
84    let source_path = resolve_source_path(source_file)?;
85
86    let source_dir = source_path
87        .parent()
88        .ok_or("Source file has no parent directory")?;
89
90    let manifest_dir = find_manifest_dir(source_dir).ok_or("Could not find Cargo.toml")?;
91    syncdoc_debug!("  manifest_dir: {}", manifest_dir.display());
92
93    let cargo_toml_path = manifest_dir.join("Cargo.toml");
94    let docs_path = get_attribute_from_cargo_toml(cargo_toml_path.to_str().unwrap(), "docs-path")?
95        .ok_or("docs-path not found")?;
96    syncdoc_debug!("  docs_path from toml: {}", docs_path);
97
98    let manifest_path = manifest_dir.canonicalize()?;
99    syncdoc_debug!("  manifest_path (canonical): {}", manifest_path.display());
100
101    let source_dir_canonical = source_dir.canonicalize()?;
102    syncdoc_debug!(
103        "  source_dir (canonical): {}",
104        source_dir_canonical.display()
105    );
106
107    // Security check: ensure source_dir is within manifest_dir
108    if !source_dir_canonical.starts_with(&manifest_path) {
109        return Err("Source file is outside the manifest directory (security violation)".into());
110    }
111
112    // Calculate number of ".." needed to go from source_dir to manifest_dir
113    let relative_path = source_dir_canonical
114        .strip_prefix(&manifest_path)
115        .map_err(|_| "Failed to strip prefix")?;
116    syncdoc_debug!("  relative_path (stripped): {}", relative_path.display());
117
118    let depth = relative_path.components().count();
119    syncdoc_debug!("  depth: {}", depth);
120
121    let mut result = PathBuf::new();
122
123    for _ in 0..depth {
124        result.push("..");
125    }
126
127    result.push(&docs_path);
128    let result_str = result.to_string_lossy().to_string();
129    syncdoc_debug!("  final result: {}", result_str);
130    Ok(result_str)
131}
132
133#[cfg(test)]
134mod docs_path_tests {
135    use super::*;
136    use std::io::Write;
137    use tempfile::NamedTempFile;
138
139    fn get_docs_path_from_file(
140        cargo_toml_path: &str,
141    ) -> Result<String, Box<dyn std::error::Error>> {
142        let docs_path = get_attribute_from_cargo_toml(cargo_toml_path, "docs-path")?
143            .ok_or("docs-path not found")?;
144        Ok(docs_path)
145    }
146
147    #[test]
148    fn test_docs_path_with_following_section() {
149        let content = r#"
150[package]
151name = "myproject"
152
153[package.metadata.syncdoc]
154docs-path = "docs"
155
156[dependencies]
157serde = "1.0"
158"#;
159        let mut temp = NamedTempFile::new().unwrap();
160        write!(temp, "{}", content).unwrap();
161        temp.flush().unwrap();
162
163        let result = get_docs_path_from_file(temp.path().to_str().unwrap()).unwrap();
164        assert_eq!(result, "docs");
165    }
166
167    #[test]
168    fn test_docs_path_at_eof() {
169        let content = r#"
170[package]
171name = "myproject"
172
173[package.metadata.syncdoc]
174docs-path = "documentation"
175"#;
176        let mut temp = NamedTempFile::new().unwrap();
177        write!(temp, "{}", content).unwrap();
178        temp.flush().unwrap();
179
180        let result = get_docs_path_from_file(temp.path().to_str().unwrap()).unwrap();
181        assert_eq!(result, "documentation");
182    }
183
184    #[test]
185    fn test_docs_path_with_extra_whitespace() {
186        let content = r#"
187[package.metadata.syncdoc]
188  docs-path  =  "my-docs"
189"#;
190        let mut temp = NamedTempFile::new().unwrap();
191        write!(temp, "{}", content).unwrap();
192        temp.flush().unwrap();
193
194        let result = get_docs_path_from_file(temp.path().to_str().unwrap()).unwrap();
195        assert_eq!(result, "my-docs");
196    }
197
198    #[test]
199    fn test_docs_path_without_quotes() {
200        let content = r#"
201[package.metadata.syncdoc]
202docs-path = docs
203"#;
204        let mut temp = NamedTempFile::new().unwrap();
205        write!(temp, "{}", content).unwrap();
206        temp.flush().unwrap();
207
208        let result = get_docs_path_from_file(temp.path().to_str().unwrap()).unwrap();
209        assert_eq!(result, "docs");
210    }
211
212    #[test]
213    fn test_missing_syncdoc_section() {
214        let content = r#"
215[package]
216name = "myproject"
217"#;
218        let mut temp = NamedTempFile::new().unwrap();
219        write!(temp, "{}", content).unwrap();
220        temp.flush().unwrap();
221
222        let result = get_docs_path_from_file(temp.path().to_str().unwrap());
223        assert!(result.is_err());
224    }
225
226    #[test]
227    fn test_missing_docs_path_field() {
228        let content = r#"
229[package.metadata.syncdoc]
230other-field = "value"
231"#;
232        let mut temp = NamedTempFile::new().unwrap();
233        write!(temp, "{}", content).unwrap();
234        temp.flush().unwrap();
235
236        let result = get_docs_path_from_file(temp.path().to_str().unwrap());
237        assert!(result.is_err());
238        assert!(result
239            .unwrap_err()
240            .to_string()
241            .contains("docs-path not found"));
242    }
243
244    #[test]
245    fn test_docs_path_with_multiple_fields() {
246        let content = r#"
247[package.metadata.syncdoc]
248enable = true
249docs-path = "api-docs"
250output-format = "markdown"
251
252[dependencies]
253"#;
254        let mut temp = NamedTempFile::new().unwrap();
255        write!(temp, "{}", content).unwrap();
256        temp.flush().unwrap();
257
258        let result = get_docs_path_from_file(temp.path().to_str().unwrap()).unwrap();
259        assert_eq!(result, "api-docs");
260    }
261}
262
263#[cfg(test)]
264mod relative_path_tests {
265    use super::*;
266    use std::fs;
267    use std::io::Write;
268    use tempfile::TempDir;
269
270    #[test]
271    fn test_get_docs_path_with_relative_source_file() {
272        // Create a temporary directory structure that mimics a Rust project
273        let temp_dir = TempDir::new().unwrap();
274        let project_root = temp_dir.path();
275
276        // Create Cargo.toml with syncdoc config
277        let cargo_toml_path = project_root.join("Cargo.toml");
278        let mut cargo_toml = fs::File::create(&cargo_toml_path).unwrap();
279        write!(
280            cargo_toml,
281            r#"
282[package]
283name = "test-project"
284
285[package.metadata.syncdoc]
286docs-path = "docs"
287"#
288        )
289        .unwrap();
290        cargo_toml.flush().unwrap();
291
292        // Create src directory
293        let src_dir = project_root.join("src");
294        fs::create_dir(&src_dir).unwrap();
295
296        // Create a dummy source file
297        let lib_rs = src_dir.join("lib.rs");
298        fs::File::create(&lib_rs).unwrap();
299
300        // Change to the project directory so relative paths work
301        let original_dir = std::env::current_dir().unwrap();
302        std::env::set_current_dir(project_root).unwrap();
303
304        // Test with a RELATIVE path (this is what proc macros give us)
305        let result = get_docs_path("src/lib.rs");
306
307        // Restore original directory
308        std::env::set_current_dir(original_dir).unwrap();
309
310        // This should succeed with the fix, fail without it
311        assert!(
312            result.is_ok(),
313            "Should handle relative source file paths. Error: {:?}",
314            result.err()
315        );
316
317        let docs_path = result.unwrap();
318        assert_eq!(docs_path, "../docs");
319    }
320
321    #[test]
322    fn test_get_docs_path_with_nested_relative_source_file() {
323        let temp_dir = TempDir::new().unwrap();
324        let project_root = temp_dir.path();
325
326        let cargo_toml_path = project_root.join("Cargo.toml");
327        let mut cargo_toml = fs::File::create(&cargo_toml_path).unwrap();
328        write!(
329            cargo_toml,
330            r#"
331[package]
332name = "test-project"
333
334[package.metadata.syncdoc]
335docs-path = "documentation"
336"#
337        )
338        .unwrap();
339        cargo_toml.flush().unwrap();
340
341        let src_dir = project_root.join("src");
342        fs::create_dir(&src_dir).unwrap();
343
344        let nested_dir = src_dir.join("nested");
345        fs::create_dir(&nested_dir).unwrap();
346
347        let nested_file = nested_dir.join("module.rs");
348        fs::File::create(&nested_file).unwrap();
349
350        let original_dir = std::env::current_dir().unwrap();
351        std::env::set_current_dir(project_root).unwrap();
352
353        // Test with nested relative path
354        let result = get_docs_path("src/nested/module.rs");
355
356        std::env::set_current_dir(original_dir).unwrap();
357
358        assert!(result.is_ok(), "Should handle nested relative paths");
359        let docs_path = result.unwrap();
360        assert_eq!(docs_path, "../../documentation");
361    }
362}
363
364#[cfg(test)]
365mod cfg_attr_tests {
366    use super::*;
367    use std::io::Write;
368    use tempfile::NamedTempFile;
369
370    fn get_cfg_attr_from_file(cargo_toml_path: &str) -> Result<String, Box<dyn std::error::Error>> {
371        let cfg_attr = get_attribute_from_cargo_toml(cargo_toml_path, "cfg-attr")?
372            .ok_or("cfg-attr not found")?;
373        Ok(cfg_attr)
374    }
375
376    #[test]
377    fn test_cfg_attr_not_set() {
378        let content = r#"
379[package]
380name = "myproject"
381
382[package.metadata.syncdoc]
383"#;
384        let mut temp = NamedTempFile::new().unwrap();
385        write!(temp, "{}", content).unwrap();
386        temp.flush().unwrap();
387
388        let result = get_cfg_attr_from_file(temp.path().to_str().unwrap());
389        assert!(result.is_err());
390        assert!(result
391            .unwrap_err()
392            .to_string()
393            .contains("cfg-attr not found"));
394    }
395
396    #[test]
397    fn test_cfg_attr_set_as_doc() {
398        let content = r#"
399[package]
400name = "myproject"
401
402[package.metadata.syncdoc]
403cfg-attr = "doc"
404"#;
405        let mut temp = NamedTempFile::new().unwrap();
406        write!(temp, "{}", content).unwrap();
407        temp.flush().unwrap();
408
409        let result = get_cfg_attr_from_file(temp.path().to_str().unwrap()).unwrap();
410        assert_eq!(result, "doc");
411    }
412
413    #[test]
414    fn test_cfg_attr_set_as_custom() {
415        let content = r#"
416[package]
417name = "myproject"
418
419[package.metadata.syncdoc]
420cfg-attr = "a-custom-attr"
421"#;
422        let mut temp = NamedTempFile::new().unwrap();
423        write!(temp, "{}", content).unwrap();
424        temp.flush().unwrap();
425
426        let result = get_cfg_attr_from_file(temp.path().to_str().unwrap()).unwrap();
427        assert_eq!(result, "a-custom-attr");
428    }
429}