Skip to main content

fastskill_core/core/
registry_index.rs

1//! Crates.io-like registry index management
2
3use crate::core::service::ServiceError;
4use crate::security::validate_path_component;
5use chrono::{DateTime, Utc};
6use semver;
7use serde::{Deserialize, Serialize};
8use sha2::{Digest, Sha256};
9use std::collections::HashMap;
10use std::fs;
11use std::io::{Read, Write};
12use std::path::{Path, PathBuf};
13use walkdir::WalkDir;
14
15/// Scoped skill name normalization utilities
16pub struct ScopedSkillName;
17
18impl ScopedSkillName {
19    /// Normalize a scoped skill name to filesystem path format
20    /// Strips scope prefixes (@, :) and normalizes to org/package format
21    /// Examples:
22    /// - `@acme/web-scraper` -> `acme/web-scraper`
23    /// - `acme:web-scraper` -> `acme/web-scraper`
24    /// - `acme/web-scraper` -> `acme/web-scraper`
25    /// - `web-scraper` -> `web-scraper` (no scope, returns as-is)
26    pub fn normalize(scoped_name: &str) -> String {
27        let trimmed = scoped_name.trim();
28
29        // Remove @ prefix if present
30        let without_at = trimmed.strip_prefix('@').unwrap_or(trimmed);
31
32        // Replace : with / if present
33        without_at.replace(':', "/")
34    }
35}
36
37/// Create registry index structure
38pub fn create_registry_structure(base_path: &Path) -> Result<(), ServiceError> {
39    fs::create_dir_all(base_path).map_err(ServiceError::Io)?;
40    Ok(())
41}
42
43/// Get the index file path for a skill using org/package directory structure
44/// Format: {org}/{package}
45/// skill_id must always be in "org/package" format (enforced at publish time)
46pub fn get_skill_index_path(registry_path: &Path, skill_id: &str) -> Result<PathBuf, ServiceError> {
47    // skill_id is always in org/package format (enforced at publish time)
48    // Organization is the authenticated user's username (lowercase, filesystem-safe)
49    let parts: Vec<&str> = skill_id.split('/').collect();
50
51    // Enforce that skill_id must have exactly 2 parts (org/package)
52    if parts.len() != 2 {
53        return Err(ServiceError::Validation(format!(
54            "Invalid skill_id format: must be 'org/package', got: {}",
55            skill_id
56        )));
57    }
58
59    // Validate and get safe strings for path construction
60    let org = validate_path_component(parts[0])
61        .map_err(|e| ServiceError::Validation(format!("Invalid org: {}", e)))?;
62    let package = validate_path_component(parts[1])
63        .map_err(|e| ServiceError::Validation(format!("Invalid package: {}", e)))?;
64
65    Ok(registry_path.join(&org).join(&package))
66}
67
68/// Update registry index with a new skill version
69/// Appends newline-delimited JSON to single file per skill (crates.io format)
70pub fn update_skill_version(
71    skill_id: &str,
72    version: &str,
73    metadata: &VersionMetadata,
74    registry_path: &Path,
75) -> Result<(), ServiceError> {
76    // Get index file path using crates.io directory structure
77    let index_path = get_skill_index_path(registry_path, skill_id)?;
78
79    // Create parent directories if they don't exist
80    if let Some(parent) = index_path.parent() {
81        fs::create_dir_all(parent).map_err(ServiceError::Io)?;
82    }
83
84    // Create version entry
85    let entry = VersionEntry {
86        scoped_name: None,
87        name: skill_id.to_string(),
88        vers: version.to_string(),
89        deps: metadata.deps.clone(),
90        cksum: metadata.cksum.clone(),
91        features: metadata.features.clone(),
92        yanked: metadata.yanked,
93        links: metadata.links.clone(),
94        download_url: metadata.download_url.clone(),
95        published_at: metadata.published_at.clone(),
96        metadata: metadata.metadata.clone(),
97    };
98
99    // Serialize to compact JSON (not pretty-printed)
100    let line = serde_json::to_string(&entry)
101        .map_err(|e| ServiceError::Custom(format!("Failed to serialize index entry: {}", e)))?;
102
103    // Append line to file (newline-delimited JSON format)
104    let mut file = fs::OpenOptions::new()
105        .create(true)
106        .append(true)
107        .open(&index_path)
108        .map_err(ServiceError::Io)?;
109
110    writeln!(file, "{}", line).map_err(ServiceError::Io)?;
111
112    Ok(())
113}
114
115/// Read all versions for a skill from the index file
116/// Parses newline-delimited JSON format
117pub fn read_skill_versions(
118    registry_path: &Path,
119    skill_id: &str,
120) -> Result<Vec<VersionEntry>, ServiceError> {
121    let index_path = get_skill_index_path(registry_path, skill_id)?;
122
123    if !index_path.exists() {
124        return Ok(Vec::new());
125    }
126
127    // Canonicalize index_path to prevent path traversal
128    let safe_index_path = index_path.canonicalize().map_err(ServiceError::Io)?;
129
130    // Ensure index_path is under registry_path
131    let canonical_registry = registry_path.canonicalize().map_err(ServiceError::Io)?;
132    if !safe_index_path.starts_with(&canonical_registry) {
133        return Err(ServiceError::Custom(
134            "Index path escapes registry directory".to_string(),
135        ));
136    }
137
138    let content = fs::read_to_string(&safe_index_path).map_err(ServiceError::Io)?;
139
140    let mut entries = Vec::new();
141
142    // Parse line-by-line (newline-delimited JSON)
143    for line in content.lines() {
144        let line = line.trim();
145        if line.is_empty() {
146            continue;
147        }
148
149        match serde_json::from_str::<VersionEntry>(line) {
150            Ok(entry) => entries.push(entry),
151            Err(e) => {
152                // Log corruption event but continue parsing other lines
153                // This allows the system to serve other skills normally
154                use tracing::error;
155                error!(
156                    "Index file corruption detected for skill {}: Failed to parse entry: {} (line: {})",
157                    skill_id, e, line
158                );
159                // Continue parsing - don't fail the entire read for one corrupted entry
160            }
161        }
162    }
163
164    Ok(entries)
165}
166
167/// Get version metadata from ZIP file
168pub fn get_version_metadata(
169    skill_id: &str,
170    zip_path: &Path,
171    download_url: &str,
172) -> Result<VersionMetadata, ServiceError> {
173    // Calculate checksum
174    let cksum = calculate_file_checksum(zip_path)?;
175
176    Ok(VersionMetadata {
177        name: skill_id.to_string(),
178        vers: String::new(), // Will be set by caller
179        deps: Vec::new(),
180        cksum,
181        features: HashMap::new(),
182        yanked: false,
183        links: None,
184        download_url: download_url.to_string(),
185        published_at: Utc::now().to_rfc3339(),
186        metadata: None, // Will be populated from skill package
187    })
188}
189
190/// Version metadata entry
191#[derive(Debug, Clone, Serialize, Deserialize)]
192pub struct VersionMetadata {
193    pub name: String,
194    pub vers: String,
195    pub deps: Vec<Dependency>,
196    pub cksum: String,
197    pub features: HashMap<String, Vec<String>>,
198    pub yanked: bool,
199    #[serde(skip_serializing_if = "Option::is_none")]
200    pub links: Option<String>,
201    pub download_url: String,
202    pub published_at: String,
203    #[serde(default)]
204    pub metadata: Option<IndexMetadata>,
205}
206
207/// Index metadata (description, author, tags, etc.)
208#[derive(Debug, Clone, Serialize, Deserialize)]
209pub struct IndexMetadata {
210    pub description: Option<String>,
211    pub author: Option<String>,
212    pub license: Option<String>,
213    pub repository: Option<String>,
214}
215
216/// Version entry in index JSON file
217#[derive(Debug, Clone, Serialize, Deserialize)]
218pub struct VersionEntry {
219    pub name: String,
220    pub vers: String,
221    pub deps: Vec<Dependency>,
222    pub cksum: String,
223    pub features: HashMap<String, Vec<String>>,
224    pub yanked: bool,
225    #[serde(skip_serializing_if = "Option::is_none")]
226    pub links: Option<String>,
227    pub download_url: String,
228    pub published_at: String,
229    #[serde(default)]
230    pub metadata: Option<IndexMetadata>,
231    /// Original scoped name (e.g., `@org/package`) if the skill uses scoped naming
232    #[serde(skip_serializing_if = "Option::is_none")]
233    pub scoped_name: Option<String>,
234}
235
236/// Dependency entry for registry index
237#[derive(Debug, Clone, Serialize, Deserialize)]
238pub struct Dependency {
239    pub name: String,
240    pub req: String,
241    pub features: Vec<String>,
242    pub optional: bool,
243    pub default_features: bool,
244    pub target: Option<String>,
245    pub kind: Option<String>,
246}
247
248/// Options for listing skills from registry
249#[derive(Debug, Clone, Default)]
250pub struct ListSkillsOptions {
251    pub scope: Option<String>,
252    pub all_versions: bool,
253    pub include_pre_release: bool,
254}
255
256/// Summary of a skill from the registry index
257#[derive(Debug, Clone, Serialize, Deserialize)]
258pub struct SkillSummary {
259    pub id: String,
260    pub scope: String,
261    pub name: String,
262    pub description: String,
263    pub latest_version: String,
264    #[serde(
265        serialize_with = "serialize_datetime_option",
266        deserialize_with = "deserialize_datetime_option"
267    )]
268    pub published_at: Option<DateTime<Utc>>,
269    #[serde(skip_serializing_if = "Option::is_none")]
270    pub versions: Option<Vec<String>>,
271}
272
273/// Serialize DateTime<Utc> as ISO 8601 string
274fn serialize_datetime_option<S>(
275    dt: &Option<DateTime<Utc>>,
276    serializer: S,
277) -> Result<S::Ok, S::Error>
278where
279    S: serde::Serializer,
280{
281    match dt {
282        Some(dt) => serializer.serialize_str(&dt.to_rfc3339()),
283        None => serializer.serialize_none(),
284    }
285}
286
287/// Deserialize ISO 8601 string to DateTime<Utc>
288fn deserialize_datetime_option<'de, D>(deserializer: D) -> Result<Option<DateTime<Utc>>, D::Error>
289where
290    D: serde::Deserializer<'de>,
291{
292    use serde::Deserialize;
293    let s: Option<String> = Option::deserialize(deserializer)?;
294    match s {
295        Some(s) => DateTime::parse_from_rfc3339(&s)
296            .map(|dt| Some(dt.with_timezone(&Utc)))
297            .map_err(serde::de::Error::custom),
298        None => Ok(None),
299    }
300}
301
302/// Calculate SHA256 checksum of a file
303fn calculate_file_checksum(file_path: &Path) -> Result<String, ServiceError> {
304    let mut file = fs::File::open(file_path).map_err(ServiceError::Io)?;
305
306    let mut hasher = Sha256::new();
307    let mut buffer = [0; 8192];
308
309    loop {
310        let bytes_read = file.read(&mut buffer).map_err(ServiceError::Io)?;
311
312        if bytes_read == 0 {
313            break;
314        }
315
316        hasher.update(&buffer[..bytes_read]);
317    }
318
319    let hash = format!("sha256:{:x}", hasher.finalize());
320    Ok(hash)
321}
322
323/// Migrate old index format to new crates.io format
324/// Old format: index/{first2}/{next2}/{skill-id}-{version}.json
325/// New format: {first2}/{next2}/{skill-id} (or 1/{name}, 2/{name}, 3/{first}/{name})
326pub fn migrate_index_format(
327    old_registry_path: &Path,
328    new_registry_path: &Path,
329) -> Result<usize, ServiceError> {
330    use walkdir::WalkDir;
331
332    let mut migrated_count = 0;
333
334    // Walk through old index structure
335    let index_dir = old_registry_path.join("index");
336    if !index_dir.exists() {
337        return Ok(0);
338    }
339
340    // Group files by skill ID
341    let mut skill_versions: std::collections::HashMap<String, Vec<(String, VersionEntry)>> =
342        std::collections::HashMap::new();
343
344    for entry in WalkDir::new(&index_dir) {
345        let entry = entry.map_err(|e| ServiceError::Io(e.into()))?;
346        let path = entry.path();
347
348        if path.is_file() {
349            // Parse old filename format: {skill-id}-{version}.json
350            if let Some(file_name) = path.file_name().and_then(|n| n.to_str()) {
351                if let Some((skill_id, version)) = parse_old_index_filename(file_name) {
352                    // Read old format file
353                    let content = fs::read_to_string(path).map_err(ServiceError::Io)?;
354
355                    if let Ok(entry) = serde_json::from_str::<VersionEntry>(&content) {
356                        skill_versions
357                            .entry(skill_id)
358                            .or_default()
359                            .push((version, entry));
360                    }
361                }
362            }
363        }
364    }
365
366    // Write new format files
367    for (skill_id, versions) in skill_versions {
368        let new_index_path = get_skill_index_path(new_registry_path, &skill_id)?;
369
370        // Create parent directories
371        if let Some(parent) = new_index_path.parent() {
372            fs::create_dir_all(parent).map_err(ServiceError::Io)?;
373        }
374
375        // Write all versions to single file
376        let mut file = fs::File::create(&new_index_path).map_err(ServiceError::Io)?;
377
378        for (_, entry) in versions {
379            let line = serde_json::to_string(&entry)
380                .map_err(|e| ServiceError::Custom(format!("Failed to serialize entry: {}", e)))?;
381            writeln!(file, "{}", line).map_err(ServiceError::Io)?;
382        }
383
384        migrated_count += 1;
385    }
386
387    Ok(migrated_count)
388}
389
390/// Parse old index filename format: {skill-id}-{version}.json
391fn parse_old_index_filename(filename: &str) -> Option<(String, String)> {
392    let without_ext = filename.strip_suffix(".json")?;
393    if let Some(last_dash) = without_ext.rfind('-') {
394        let skill_id = without_ext[..last_dash].to_string();
395        let version = without_ext[last_dash + 1..].to_string();
396        Some((skill_id, version))
397    } else {
398        None
399    }
400}
401
402/// Extract scope from skill ID (format: scope/name)
403fn extract_scope(skill_id: &str) -> Option<(String, String)> {
404    let parts: Vec<&str> = skill_id.split('/').collect();
405    if parts.len() == 2 {
406        Some((parts[0].to_string(), parts[1].to_string()))
407    } else {
408        None
409    }
410}
411
412/// Determine if a version is a pre-release
413fn is_pre_release(version: &str) -> bool {
414    semver::Version::parse(version)
415        .map(|v| !v.pre.is_empty())
416        .unwrap_or(false)
417}
418
419/// Determine latest version from a list of version entries
420/// Returns the highest stable version, or highest pre-release if include_pre_release is true
421fn determine_latest_version(
422    entries: &[VersionEntry],
423    include_pre_release: bool,
424) -> Option<&VersionEntry> {
425    let mut valid_entries: Vec<&VersionEntry> = entries
426        .iter()
427        .filter(|e| !e.yanked)
428        .filter(|e| include_pre_release || !is_pre_release(&e.vers))
429        .collect();
430
431    if valid_entries.is_empty() {
432        return None;
433    }
434
435    // Sort by semantic version (highest first)
436    valid_entries.sort_by(|a, b| {
437        let ver_a = semver::Version::parse(&a.vers).ok();
438        let ver_b = semver::Version::parse(&b.vers).ok();
439        match (ver_a, ver_b) {
440            (Some(va), Some(vb)) => vb.cmp(&va), // Reverse order (highest first)
441            (Some(_), None) => std::cmp::Ordering::Less,
442            (None, Some(_)) => std::cmp::Ordering::Greater,
443            (None, None) => b.vers.cmp(&a.vers), // String fallback
444        }
445    });
446
447    valid_entries.first().copied()
448}
449
450/// Scan registry index directory and return skill summaries
451/// This is used by the server-side HTTP endpoint
452pub async fn scan_registry_index(
453    registry_path: &Path,
454    options: &ListSkillsOptions,
455) -> Result<Vec<SkillSummary>, ServiceError> {
456    use tracing::{error, warn};
457
458    if !registry_path.exists() {
459        return Ok(Vec::new());
460    }
461
462    // Canonicalize registry_path to prevent path traversal
463    let canonical_registry = registry_path.canonicalize().map_err(ServiceError::Io)?;
464
465    let mut skill_map: std::collections::HashMap<String, Vec<VersionEntry>> =
466        std::collections::HashMap::new();
467
468    // Recursively scan registry directory
469    for entry in WalkDir::new(&canonical_registry).min_depth(1) {
470        let entry = entry.map_err(|e| ServiceError::Io(e.into()))?;
471        let path = entry.path();
472
473        // Skip directories and hidden files
474        if path.is_dir()
475            || path
476                .file_name()
477                .and_then(|n| n.to_str())
478                .map(|n| n.starts_with('.'))
479                .unwrap_or(false)
480        {
481            continue;
482        }
483
484        // Try to determine skill_id from path
485        // Path format: {registry_path}/{scope}/{name}
486        let relative_path = path.strip_prefix(registry_path).map_err(|e| {
487            ServiceError::Io(std::io::Error::new(
488                std::io::ErrorKind::InvalidInput,
489                format!("Failed to strip prefix: {}", e),
490            ))
491        })?;
492
493        let parts: Vec<&str> = relative_path
494            .components()
495            .filter_map(|c| c.as_os_str().to_str())
496            .collect();
497
498        // Must have at least 2 parts (scope/name)
499        if parts.len() < 2 {
500            warn!("Skipping invalid path structure: {:?}", path);
501            continue;
502        }
503
504        // Reconstruct skill_id from path
505        let skill_id = format!("{}/{}", parts[0], parts[1]);
506
507        // Validate skill_id format
508        if extract_scope(&skill_id).is_none() {
509            warn!("Skipping invalid skill_id format: {}", skill_id);
510            continue;
511        }
512
513        // Apply scope filter if specified
514        if let Some(ref filter_scope) = options.scope {
515            if let Some((scope, _)) = extract_scope(&skill_id) {
516                if scope != *filter_scope {
517                    continue;
518                }
519            } else {
520                continue;
521            }
522        }
523
524        // Read version entries from index file
525        match read_skill_versions(registry_path, &skill_id) {
526            Ok(entries) => {
527                if !entries.is_empty() {
528                    skill_map.insert(skill_id, entries);
529                }
530            }
531            Err(e) => {
532                error!("Failed to read index file for skill {}: {}", skill_id, e);
533                // Continue processing other skills
534            }
535        }
536    }
537
538    let mut summaries = Vec::new();
539
540    for (skill_id, entries) in skill_map {
541        let (scope, name) = extract_scope(&skill_id).ok_or_else(|| {
542            ServiceError::Custom(format!("Invalid skill_id format: {}", skill_id))
543        })?;
544
545        if options.all_versions {
546            // Return one summary per version
547            let mut seen_versions = std::collections::HashSet::new();
548            for entry in entries {
549                // Skip yanked versions
550                if entry.yanked {
551                    continue;
552                }
553
554                // Apply pre-release filter
555                if !options.include_pre_release && is_pre_release(&entry.vers) {
556                    continue;
557                }
558
559                // Deduplicate: use first occurrence of each version
560                if seen_versions.contains(&entry.vers) {
561                    warn!(
562                        "Duplicate version {} for skill {}, skipping",
563                        entry.vers, skill_id
564                    );
565                    continue;
566                }
567                seen_versions.insert(entry.vers.clone());
568
569                // Parse published_at
570                let published_at = entry.published_at.parse::<DateTime<Utc>>().ok();
571
572                // Extract description from metadata
573                let description = entry
574                    .metadata
575                    .as_ref()
576                    .and_then(|m| m.description.clone())
577                    .unwrap_or_default();
578
579                summaries.push(SkillSummary {
580                    id: skill_id.clone(),
581                    scope: scope.clone(),
582                    name: name.clone(),
583                    description,
584                    latest_version: entry.vers.clone(),
585                    published_at,
586                    versions: None,
587                });
588            }
589        } else {
590            // Return one summary per skill (latest version)
591            if let Some(latest_entry) =
592                determine_latest_version(&entries, options.include_pre_release)
593            {
594                // Parse published_at
595                let published_at = latest_entry.published_at.parse::<DateTime<Utc>>().ok();
596
597                // Extract description from metadata
598                let description = latest_entry
599                    .metadata
600                    .as_ref()
601                    .and_then(|m| m.description.clone())
602                    .unwrap_or_default();
603
604                // Collect all versions if needed
605                let versions = if options.include_pre_release {
606                    Some(
607                        entries
608                            .iter()
609                            .filter(|e| !e.yanked)
610                            .map(|e| e.vers.clone())
611                            .collect(),
612                    )
613                } else {
614                    Some(
615                        entries
616                            .iter()
617                            .filter(|e| !e.yanked && !is_pre_release(&e.vers))
618                            .map(|e| e.vers.clone())
619                            .collect(),
620                    )
621                };
622
623                summaries.push(SkillSummary {
624                    id: skill_id,
625                    scope,
626                    name,
627                    description,
628                    latest_version: latest_entry.vers.clone(),
629                    published_at,
630                    versions,
631                });
632            }
633        }
634    }
635
636    // Sort alphabetically by skill ID (ascending)
637    summaries.sort_by(|a, b| a.id.cmp(&b.id));
638
639    Ok(summaries)
640}
641
642#[cfg(test)]
643#[allow(clippy::unwrap_used)]
644mod tests {
645    use super::*;
646    use tempfile::TempDir;
647
648    #[test]
649    fn test_get_skill_index_path() {
650        let temp_dir = TempDir::new().unwrap();
651        let registry_path = temp_dir.path();
652
653        // Test org/package format
654        let path = get_skill_index_path(registry_path, "acme/web-scraper").unwrap();
655        assert_eq!(path, registry_path.join("acme").join("web-scraper"));
656
657        // Test another org/package
658        let path = get_skill_index_path(registry_path, "myorg/data-processor").unwrap();
659        assert_eq!(path, registry_path.join("myorg").join("data-processor"));
660
661        // Test single character org/package
662        let path = get_skill_index_path(registry_path, "a/tool").unwrap();
663        assert_eq!(path, registry_path.join("a").join("tool"));
664
665        // Test that invalid format returns error
666        assert!(get_skill_index_path(registry_path, "invalid-format").is_err());
667        assert!(get_skill_index_path(registry_path, "single").is_err());
668        assert!(get_skill_index_path(registry_path, "a/b/c").is_err());
669    }
670
671    #[test]
672    fn test_update_and_read_skill_versions() {
673        let temp_dir = TempDir::new().unwrap();
674        let registry_path = temp_dir.path();
675
676        let skill_id = "acme/test-skill";
677        let version1 = "1.0.0";
678        let version2 = "1.0.1";
679
680        // Create metadata
681        let metadata1 = VersionMetadata {
682            name: skill_id.to_string(),
683            vers: version1.to_string(),
684            deps: Vec::new(),
685            cksum: "sha256:test1".to_string(),
686            features: HashMap::new(),
687            yanked: false,
688            links: None,
689            download_url: "http://example.com/v1.zip".to_string(),
690            published_at: "2024-01-01T00:00:00Z".to_string(),
691            metadata: None,
692        };
693
694        let metadata2 = VersionMetadata {
695            name: skill_id.to_string(),
696            vers: version2.to_string(),
697            deps: Vec::new(),
698            cksum: "sha256:test2".to_string(),
699            features: HashMap::new(),
700            yanked: false,
701            links: None,
702            download_url: "http://example.com/v2.zip".to_string(),
703            published_at: "2024-01-02T00:00:00Z".to_string(),
704            metadata: None,
705        };
706
707        // Update with first version
708        update_skill_version(skill_id, version1, &metadata1, registry_path).unwrap();
709
710        // Update with second version
711        update_skill_version(skill_id, version2, &metadata2, registry_path).unwrap();
712
713        // Read all versions
714        let entries = read_skill_versions(registry_path, skill_id).unwrap();
715        assert_eq!(entries.len(), 2);
716        assert_eq!(entries[0].vers, version1);
717        assert_eq!(entries[1].vers, version2);
718    }
719
720    #[test]
721    fn test_newline_delimited_format() {
722        let temp_dir = TempDir::new().unwrap();
723        let registry_path = temp_dir.path();
724
725        let skill_id = "acme/test";
726        let metadata = VersionMetadata {
727            name: skill_id.to_string(),
728            vers: "1.0.0".to_string(),
729            deps: Vec::new(),
730            cksum: "sha256:test".to_string(),
731            features: HashMap::new(),
732            yanked: false,
733            links: None,
734            download_url: "http://example.com/test.zip".to_string(),
735            published_at: "2024-01-01T00:00:00Z".to_string(),
736            metadata: None,
737        };
738
739        update_skill_version(skill_id, "1.0.0", &metadata, registry_path).unwrap();
740
741        // Read file and verify it's compact JSON (not pretty-printed)
742        let index_path = get_skill_index_path(registry_path, skill_id).unwrap();
743        let content = fs::read_to_string(&index_path).unwrap();
744
745        // Should be a single line (newline-delimited)
746        let lines: Vec<&str> = content.lines().collect();
747        assert_eq!(lines.len(), 1);
748
749        // Should not contain pretty-printing (no newlines in JSON)
750        assert!(!content.contains("\n  "));
751        assert!(!content.contains("    "));
752    }
753}