Skip to main content

cuenv_core/lockfile/
mod.rs

1//! Lockfile types for cuenv tool management.
2//!
3//! The lockfile (`cuenv.lock`) stores resolved tool digests for
4//! reproducible, hermetic builds. It supports multiple tool sources:
5//! GitHub releases, Nix flakes, and OCI images.
6//!
7//! ## Structure (v2)
8//!
9//! ```toml
10//! version = 2
11//!
12//! # Tools section - multi-source tool management
13//! [tools.jq]
14//! version = "1.7.1"
15//!
16//!   [tools.jq.platforms."darwin-arm64"]
17//!   provider = "github"
18//!   digest = "sha256:abc..."
19//!   source = { repo = "jqlang/jq", tag = "jq-1.7.1", asset = "jq-macos-arm64" }
20//!
21//!   [tools.jq.platforms."linux-x86_64"]
22//!   provider = "github"
23//!   digest = "sha256:def..."
24//!   source = { repo = "jqlang/jq", tag = "jq-1.7.1", asset = "jq-linux-amd64" }
25//!
26//! [tools.rust]
27//! version = "1.83.0"
28//!
29//!   [tools.rust.platforms."darwin-arm64"]
30//!   provider = "nix"
31//!   digest = "sha256:ghi..."
32//!   source = { flake = "nixpkgs", package = "rustc" }
33//!
34//! # Legacy artifacts section (for OCI images)
35//! [[artifacts]]
36//! kind = "image"
37//! image = "nginx:1.25-alpine"
38//!
39//!   [artifacts.platforms]
40//!   "linux-x86_64" = { digest = "sha256:abc...", size = 1234567 }
41//! ```
42
43use serde::{Deserialize, Serialize};
44use std::collections::BTreeMap;
45use std::path::Path;
46
47/// Current lockfile format version.
48pub const LOCKFILE_VERSION: u32 = 2;
49
50/// Filename for the lockfile.
51pub const LOCKFILE_NAME: &str = "cuenv.lock";
52
53/// The root lockfile structure.
54#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
55pub struct Lockfile {
56    /// Lockfile format version (for future migrations).
57    pub version: u32,
58    /// Locked tools with per-platform resolution (v2+).
59    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
60    pub tools: BTreeMap<String, LockedTool>,
61    /// Legacy OCI artifacts (for backward compatibility with v1).
62    #[serde(default, skip_serializing_if = "Vec::is_empty")]
63    pub artifacts: Vec<LockedArtifact>,
64}
65
66impl Default for Lockfile {
67    fn default() -> Self {
68        Self {
69            version: LOCKFILE_VERSION,
70            tools: BTreeMap::new(),
71            artifacts: Vec::new(),
72        }
73    }
74}
75
76impl Lockfile {
77    /// Create a new empty lockfile.
78    #[must_use]
79    pub fn new() -> Self {
80        Self::default()
81    }
82
83    /// Load a lockfile from a TOML file.
84    ///
85    /// Returns `None` if the file doesn't exist.
86    /// Returns an error if the file exists but is invalid.
87    pub fn load(path: &Path) -> crate::Result<Option<Self>> {
88        if !path.exists() {
89            return Ok(None);
90        }
91
92        let content = std::fs::read_to_string(path)
93            .map_err(|e| crate::Error::configuration(format!("Failed to read lockfile: {}", e)))?;
94
95        let lockfile: Self = toml::from_str(&content).map_err(|e| {
96            crate::Error::configuration(format!(
97                "Failed to parse lockfile at {}: {}",
98                path.display(),
99                e
100            ))
101        })?;
102
103        // Version check for future migrations
104        if lockfile.version > LOCKFILE_VERSION {
105            return Err(crate::Error::configuration(format!(
106                "Lockfile version {} is newer than supported version {}. Please upgrade cuenv.",
107                lockfile.version, LOCKFILE_VERSION
108            )));
109        }
110
111        Ok(Some(lockfile))
112    }
113
114    /// Save the lockfile to a TOML file.
115    pub fn save(&self, path: &Path) -> crate::Result<()> {
116        let content = toml::to_string_pretty(self).map_err(|e| {
117            crate::Error::configuration(format!("Failed to serialize lockfile: {}", e))
118        })?;
119
120        std::fs::write(path, content)
121            .map_err(|e| crate::Error::configuration(format!("Failed to write lockfile: {}", e)))?;
122
123        Ok(())
124    }
125
126    /// Find an image artifact by image reference.
127    #[must_use]
128    pub fn find_image_artifact(&self, image: &str) -> Option<&LockedArtifact> {
129        self.artifacts
130            .iter()
131            .find(|a| matches!(&a.kind, ArtifactKind::Image { image: img } if img == image))
132    }
133
134    /// Find a tool by name.
135    #[must_use]
136    pub fn find_tool(&self, name: &str) -> Option<&LockedTool> {
137        self.tools.get(name)
138    }
139
140    /// Get all tool names.
141    #[must_use]
142    pub fn tool_names(&self) -> Vec<&str> {
143        self.tools.keys().map(String::as_str).collect()
144    }
145
146    /// Add or update a tool in the lockfile.
147    ///
148    /// # Errors
149    ///
150    /// Returns an error if the tool fails validation (empty platforms or
151    /// invalid digest format).
152    pub fn upsert_tool(&mut self, name: String, tool: LockedTool) -> crate::Result<()> {
153        tool.validate().map_err(|msg| {
154            crate::Error::configuration(format!("Invalid tool '{}': {}", name, msg))
155        })?;
156
157        self.tools.insert(name, tool);
158        Ok(())
159    }
160
161    /// Add or update a single platform for a tool.
162    ///
163    /// Creates the tool entry if it doesn't exist.
164    ///
165    /// # Errors
166    ///
167    /// Returns an error if the platform data is invalid.
168    pub fn upsert_tool_platform(
169        &mut self,
170        name: &str,
171        version: &str,
172        platform: &str,
173        data: LockedToolPlatform,
174    ) -> crate::Result<()> {
175        // Validate digest format
176        if !data.digest.starts_with("sha256:") && !data.digest.starts_with("sha512:") {
177            return Err(crate::Error::configuration(format!(
178                "Invalid digest format for tool '{}' platform '{}': must start with 'sha256:' or 'sha512:'",
179                name, platform
180            )));
181        }
182
183        let tool = self
184            .tools
185            .entry(name.to_string())
186            .or_insert_with(|| LockedTool {
187                version: version.to_string(),
188                platforms: BTreeMap::new(),
189            });
190
191        // Update version if it changed
192        if tool.version != version {
193            tool.version = version.to_string();
194        }
195
196        tool.platforms.insert(platform.to_string(), data);
197        Ok(())
198    }
199
200    /// Add or update an artifact in the lockfile (legacy v1 format).
201    ///
202    /// Matches artifacts by image reference.
203    ///
204    /// # Errors
205    ///
206    /// Returns an error if the artifact fails validation (empty platforms or
207    /// invalid digest format).
208    pub fn upsert_artifact(&mut self, artifact: LockedArtifact) -> crate::Result<()> {
209        // Validate the artifact before inserting
210        artifact
211            .validate()
212            .map_err(|msg| crate::Error::configuration(format!("Invalid artifact: {}", msg)))?;
213
214        // Find existing artifact with same identity (match Image by full reference)
215        let existing_idx = self
216            .artifacts
217            .iter()
218            .position(|a| match (&a.kind, &artifact.kind) {
219                (ArtifactKind::Image { image: i1 }, ArtifactKind::Image { image: i2 }) => i1 == i2,
220            });
221
222        if let Some(idx) = existing_idx {
223            self.artifacts[idx] = artifact;
224        } else {
225            self.artifacts.push(artifact);
226        }
227
228        Ok(())
229    }
230}
231
232/// A locked OCI artifact with platform-specific digests.
233#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
234pub struct LockedArtifact {
235    /// The kind of artifact (registry package or container image).
236    #[serde(flatten)]
237    pub kind: ArtifactKind,
238    /// Platform-specific resolution data.
239    /// Keys are platform strings like "darwin-arm64", "linux-x86_64".
240    pub platforms: BTreeMap<String, PlatformData>,
241}
242
243impl LockedArtifact {
244    /// Get the digest for the current platform.
245    #[must_use]
246    pub fn digest_for_current_platform(&self) -> Option<&str> {
247        let platform = current_platform();
248        self.platforms.get(&platform).map(|p| p.digest.as_str())
249    }
250
251    /// Get platform data for the current platform.
252    #[must_use]
253    pub fn platform_data(&self) -> Option<&PlatformData> {
254        let platform = current_platform();
255        self.platforms.get(&platform)
256    }
257
258    /// Validate the artifact has valid data.
259    ///
260    /// Checks:
261    /// - At least one platform is present
262    /// - All digests have valid format (sha256: or sha512: prefix)
263    fn validate(&self) -> Result<(), String> {
264        if self.platforms.is_empty() {
265            return Err("Artifact must have at least one platform".to_string());
266        }
267
268        for (platform, data) in &self.platforms {
269            if !data.digest.starts_with("sha256:") && !data.digest.starts_with("sha512:") {
270                return Err(format!(
271                    "Invalid digest format for platform '{}': must start with 'sha256:' or 'sha512:'",
272                    platform
273                ));
274            }
275        }
276
277        Ok(())
278    }
279}
280
281/// The kind of OCI artifact.
282#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
283#[serde(tag = "kind", rename_all = "lowercase")]
284pub enum ArtifactKind {
285    /// An OCI image (container images).
286    Image {
287        /// Full image reference (e.g., "nginx:1.25-alpine").
288        image: String,
289    },
290}
291
292/// Platform-specific artifact data.
293#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
294pub struct PlatformData {
295    /// Content-addressable digest (e.g., "sha256:abc123...").
296    pub digest: String,
297    /// Size in bytes (for progress reporting).
298    #[serde(skip_serializing_if = "Option::is_none")]
299    pub size: Option<u64>,
300}
301
302/// A locked tool with version and per-platform resolution (v2+).
303#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
304pub struct LockedTool {
305    /// Version string (e.g., "1.7.1").
306    pub version: String,
307    /// Platform-specific resolution data.
308    /// Keys are platform strings like "darwin-arm64", "linux-x86_64".
309    pub platforms: BTreeMap<String, LockedToolPlatform>,
310}
311
312impl LockedTool {
313    /// Get platform data for the current platform.
314    #[must_use]
315    pub fn current_platform(&self) -> Option<&LockedToolPlatform> {
316        let platform = current_platform();
317        self.platforms.get(&platform)
318    }
319
320    /// Validate the locked tool has valid data.
321    fn validate(&self) -> Result<(), String> {
322        if self.platforms.is_empty() {
323            return Err("Tool must have at least one platform".to_string());
324        }
325
326        for (platform, data) in &self.platforms {
327            if !data.digest.starts_with("sha256:") && !data.digest.starts_with("sha512:") {
328                return Err(format!(
329                    "Invalid digest format for platform '{}': must start with 'sha256:' or 'sha512:'",
330                    platform
331                ));
332            }
333        }
334
335        Ok(())
336    }
337}
338
339/// Platform-specific tool resolution data (v2+).
340#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
341pub struct LockedToolPlatform {
342    /// Provider that resolved this tool (e.g., "github", "nix", "oci").
343    pub provider: String,
344    /// Content-addressable digest (e.g., "sha256:abc123...").
345    pub digest: String,
346    /// Provider-specific source data (serialized as inline table).
347    /// For GitHub: `{ repo = "...", tag = "...", asset = "..." }`
348    /// For Nix: `{ flake = "...", package = "..." }`
349    /// For OCI: `{ image = "...", path = "..." }`
350    pub source: serde_json::Value,
351    /// Size in bytes (for progress reporting).
352    #[serde(skip_serializing_if = "Option::is_none")]
353    pub size: Option<u64>,
354    /// Runtime dependencies (other tool names).
355    #[serde(default, skip_serializing_if = "Vec::is_empty")]
356    pub dependencies: Vec<String>,
357}
358
359/// Get the current platform string.
360///
361/// Format: `{os}-{arch}` where:
362/// - os: "darwin", "linux", "windows"
363/// - arch: "x86_64", "arm64", "aarch64"
364#[must_use]
365pub fn current_platform() -> String {
366    let os = std::env::consts::OS;
367    let arch = std::env::consts::ARCH;
368
369    // Normalize arch names
370    let arch = match arch {
371        "aarch64" => "arm64",
372        other => other,
373    };
374
375    format!("{}-{}", os, arch)
376}
377
378/// Normalize a platform string to our canonical format.
379#[must_use]
380pub fn normalize_platform(platform: &str) -> String {
381    let platform = platform.to_lowercase();
382
383    // Handle various platform formats
384    platform
385        .replace("macos", "darwin")
386        .replace("osx", "darwin")
387        .replace("amd64", "x86_64")
388        .replace("aarch64", "arm64")
389}
390
391#[cfg(test)]
392mod tests {
393    use super::*;
394
395    #[test]
396    fn test_lockfile_serialization() {
397        let mut lockfile = Lockfile::new();
398
399        // OCI image artifact
400        lockfile.artifacts.push(LockedArtifact {
401            kind: ArtifactKind::Image {
402                image: "nginx:1.25-alpine".to_string(),
403            },
404            platforms: BTreeMap::from([
405                (
406                    "darwin-arm64".to_string(),
407                    PlatformData {
408                        digest: "sha256:abc123".to_string(),
409                        size: Some(1234567),
410                    },
411                ),
412                (
413                    "linux-x86_64".to_string(),
414                    PlatformData {
415                        digest: "sha256:def456".to_string(),
416                        size: Some(1345678),
417                    },
418                ),
419            ]),
420        });
421
422        let toml_str = toml::to_string_pretty(&lockfile).unwrap();
423        assert!(toml_str.contains("version = 2"));
424        assert!(toml_str.contains("kind = \"image\""));
425        assert!(toml_str.contains("nginx:1.25-alpine"));
426
427        // Round-trip test
428        let parsed: Lockfile = toml::from_str(&toml_str).unwrap();
429        assert_eq!(parsed, lockfile);
430    }
431
432    #[test]
433    fn test_find_image_artifact() {
434        let mut lockfile = Lockfile::new();
435        lockfile.artifacts.push(LockedArtifact {
436            kind: ArtifactKind::Image {
437                image: "nginx:1.25-alpine".to_string(),
438            },
439            platforms: BTreeMap::new(),
440        });
441
442        assert!(lockfile.find_image_artifact("nginx:1.25-alpine").is_some());
443        assert!(lockfile.find_image_artifact("nginx:1.24-alpine").is_none());
444    }
445
446    #[test]
447    fn test_upsert_artifact() {
448        let mut lockfile = Lockfile::new();
449
450        let artifact1 = LockedArtifact {
451            kind: ArtifactKind::Image {
452                image: "nginx:1.25-alpine".to_string(),
453            },
454            platforms: BTreeMap::from([(
455                "darwin-arm64".to_string(),
456                PlatformData {
457                    digest: "sha256:old".to_string(),
458                    size: None,
459                },
460            )]),
461        };
462
463        lockfile.upsert_artifact(artifact1).unwrap();
464        assert_eq!(lockfile.artifacts.len(), 1);
465
466        // Update with new digest
467        let artifact2 = LockedArtifact {
468            kind: ArtifactKind::Image {
469                image: "nginx:1.25-alpine".to_string(),
470            },
471            platforms: BTreeMap::from([(
472                "darwin-arm64".to_string(),
473                PlatformData {
474                    digest: "sha256:new".to_string(),
475                    size: Some(123),
476                },
477            )]),
478        };
479
480        lockfile.upsert_artifact(artifact2).unwrap();
481        assert_eq!(lockfile.artifacts.len(), 1);
482        assert_eq!(
483            lockfile.artifacts[0].platforms["darwin-arm64"].digest,
484            "sha256:new"
485        );
486    }
487
488    #[test]
489    fn test_current_platform() {
490        let platform = current_platform();
491        // Should contain OS and arch
492        assert!(platform.contains('-'));
493        let parts: Vec<&str> = platform.split('-').collect();
494        assert_eq!(parts.len(), 2);
495    }
496
497    #[test]
498    fn test_normalize_platform() {
499        assert_eq!(normalize_platform("macos-amd64"), "darwin-x86_64");
500        assert_eq!(normalize_platform("linux-aarch64"), "linux-arm64");
501        assert_eq!(normalize_platform("Darwin-ARM64"), "darwin-arm64");
502    }
503
504    #[test]
505    fn test_upsert_artifact_validation_empty_platforms() {
506        let mut lockfile = Lockfile::new();
507
508        let artifact = LockedArtifact {
509            kind: ArtifactKind::Image {
510                image: "nginx:1.25-alpine".to_string(),
511            },
512            platforms: BTreeMap::new(), // Empty - should fail
513        };
514
515        let result = lockfile.upsert_artifact(artifact);
516        assert!(result.is_err());
517        assert!(
518            result
519                .unwrap_err()
520                .to_string()
521                .contains("at least one platform")
522        );
523    }
524
525    #[test]
526    fn test_upsert_artifact_validation_invalid_digest() {
527        let mut lockfile = Lockfile::new();
528
529        let artifact = LockedArtifact {
530            kind: ArtifactKind::Image {
531                image: "nginx:1.25-alpine".to_string(),
532            },
533            platforms: BTreeMap::from([(
534                "darwin-arm64".to_string(),
535                PlatformData {
536                    digest: "invalid-no-prefix".to_string(), // Missing sha256: prefix
537                    size: None,
538                },
539            )]),
540        };
541
542        let result = lockfile.upsert_artifact(artifact);
543        assert!(result.is_err());
544        assert!(
545            result
546                .unwrap_err()
547                .to_string()
548                .contains("Invalid digest format")
549        );
550    }
551
552    #[test]
553    fn test_artifact_validate_valid() {
554        let artifact = LockedArtifact {
555            kind: ArtifactKind::Image {
556                image: "nginx:1.25-alpine".to_string(),
557            },
558            platforms: BTreeMap::from([
559                (
560                    "darwin-arm64".to_string(),
561                    PlatformData {
562                        digest: "sha256:abc123".to_string(),
563                        size: Some(1234),
564                    },
565                ),
566                (
567                    "linux-x86_64".to_string(),
568                    PlatformData {
569                        digest: "sha512:def456".to_string(),
570                        size: None,
571                    },
572                ),
573            ]),
574        };
575
576        assert!(artifact.validate().is_ok());
577    }
578
579    #[test]
580    fn test_tools_serialization() {
581        let mut lockfile = Lockfile::new();
582
583        lockfile
584            .upsert_tool_platform(
585                "jq",
586                "1.7.1",
587                "darwin-arm64",
588                LockedToolPlatform {
589                    provider: "github".to_string(),
590                    digest: "sha256:abc123".to_string(),
591                    source: serde_json::json!({ "repo": "jqlang/jq", "tag": "jq-1.7.1", "asset": "jq-macos-arm64" }),
592                    size: Some(1234567),
593                    dependencies: vec![],
594                },
595            )
596            .unwrap();
597
598        lockfile
599            .upsert_tool_platform(
600                "jq",
601                "1.7.1",
602                "linux-x86_64",
603                LockedToolPlatform {
604                    provider: "github".to_string(),
605                    digest: "sha256:def456".to_string(),
606                    source: serde_json::json!({ "repo": "jqlang/jq", "tag": "jq-1.7.1", "asset": "jq-linux-amd64" }),
607                    size: Some(1345678),
608                    dependencies: vec![],
609                },
610            )
611            .unwrap();
612
613        let toml_str = toml::to_string_pretty(&lockfile).unwrap();
614        assert!(toml_str.contains("version = 2"));
615        assert!(toml_str.contains("[tools.jq]"));
616        assert!(toml_str.contains("provider = \"github\""));
617        assert!(toml_str.contains("digest = \"sha256:abc123\""));
618
619        // Round-trip test
620        let parsed: Lockfile = toml::from_str(&toml_str).unwrap();
621        assert_eq!(parsed.tools.len(), 1);
622        assert_eq!(parsed.tools["jq"].version, "1.7.1");
623        assert_eq!(parsed.tools["jq"].platforms.len(), 2);
624    }
625
626    #[test]
627    fn test_find_tool() {
628        let mut lockfile = Lockfile::new();
629        lockfile
630            .upsert_tool_platform(
631                "jq",
632                "1.7.1",
633                "darwin-arm64",
634                LockedToolPlatform {
635                    provider: "github".to_string(),
636                    digest: "sha256:abc123".to_string(),
637                    source: serde_json::json!({ "repo": "jqlang/jq", "tag": "jq-1.7.1", "asset": "jq-macos-arm64" }),
638                    size: None,
639                    dependencies: vec![],
640                },
641            )
642            .unwrap();
643
644        assert!(lockfile.find_tool("jq").is_some());
645        assert!(lockfile.find_tool("yq").is_none());
646    }
647
648    #[test]
649    fn test_upsert_tool_platform() {
650        let mut lockfile = Lockfile::new();
651
652        // Add first platform
653        lockfile
654            .upsert_tool_platform(
655                "bun",
656                "1.3.5",
657                "darwin-arm64",
658                LockedToolPlatform {
659                    provider: "github".to_string(),
660                    digest: "sha256:aaa".to_string(),
661                    source: serde_json::json!({ "url": "https://..." }),
662                    size: None,
663                    dependencies: vec![],
664                },
665            )
666            .unwrap();
667
668        assert_eq!(lockfile.tools.len(), 1);
669        assert_eq!(lockfile.tools["bun"].platforms.len(), 1);
670
671        // Add second platform
672        lockfile
673            .upsert_tool_platform(
674                "bun",
675                "1.3.5",
676                "linux-x86_64",
677                LockedToolPlatform {
678                    provider: "oci".to_string(),
679                    digest: "sha256:bbb".to_string(),
680                    source: serde_json::json!({ "image": "oven/bun:1.3.5" }),
681                    size: None,
682                    dependencies: vec![],
683                },
684            )
685            .unwrap();
686
687        assert_eq!(lockfile.tools.len(), 1);
688        assert_eq!(lockfile.tools["bun"].platforms.len(), 2);
689        assert_eq!(
690            lockfile.tools["bun"].platforms["darwin-arm64"].provider,
691            "github"
692        );
693        assert_eq!(
694            lockfile.tools["bun"].platforms["linux-x86_64"].provider,
695            "oci"
696        );
697    }
698
699    #[test]
700    fn test_upsert_tool_platform_invalid_digest() {
701        let mut lockfile = Lockfile::new();
702
703        let result = lockfile.upsert_tool_platform(
704            "jq",
705            "1.7.1",
706            "darwin-arm64",
707            LockedToolPlatform {
708                provider: "github".to_string(),
709                digest: "invalid".to_string(), // Missing sha256: prefix
710                source: serde_json::json!({}),
711                size: None,
712                dependencies: vec![],
713            },
714        );
715
716        assert!(result.is_err());
717        assert!(
718            result
719                .unwrap_err()
720                .to_string()
721                .contains("Invalid digest format")
722        );
723    }
724
725    #[test]
726    fn test_upsert_tool_validation_empty_platforms() {
727        let mut lockfile = Lockfile::new();
728
729        let tool = LockedTool {
730            version: "1.7.1".to_string(),
731            platforms: BTreeMap::new(), // Empty - should fail
732        };
733
734        let result = lockfile.upsert_tool("jq".to_string(), tool);
735        assert!(result.is_err());
736        assert!(
737            result
738                .unwrap_err()
739                .to_string()
740                .contains("at least one platform")
741        );
742    }
743
744    #[test]
745    fn test_tool_names() {
746        let mut lockfile = Lockfile::new();
747
748        lockfile
749            .upsert_tool_platform(
750                "jq",
751                "1.7.1",
752                "darwin-arm64",
753                LockedToolPlatform {
754                    provider: "github".to_string(),
755                    digest: "sha256:abc".to_string(),
756                    source: serde_json::json!({}),
757                    size: None,
758                    dependencies: vec![],
759                },
760            )
761            .unwrap();
762
763        lockfile
764            .upsert_tool_platform(
765                "yq",
766                "4.44.6",
767                "darwin-arm64",
768                LockedToolPlatform {
769                    provider: "github".to_string(),
770                    digest: "sha256:def".to_string(),
771                    source: serde_json::json!({}),
772                    size: None,
773                    dependencies: vec![],
774                },
775            )
776            .unwrap();
777
778        let names = lockfile.tool_names();
779        assert_eq!(names.len(), 2);
780        assert!(names.contains(&"jq"));
781        assert!(names.contains(&"yq"));
782    }
783}