use crate::domain::action::identity::{CommitDate, CommitSha, Repository, Version};
use crate::domain::action::resolved::{Commit, ResolvedRef};
use crate::domain::action::spec::Spec;
use crate::domain::action::uses_ref::RefType;
use crate::domain::lock::{Lock, LockEntry};
use serde::Deserialize;
use std::collections::HashMap;
use std::path::Path;
#[derive(Debug, Clone, Deserialize)]
pub struct FlatEntryData {
pub sha: String,
#[serde(default)]
pub version: Option<String>,
#[serde(default)]
#[expect(
dead_code,
reason = "consumed by serde for forward-compatible parsing of legacy lock files"
)]
pub comment: String,
pub repository: String,
pub ref_type: String,
pub date: String,
}
#[derive(Debug, Deserialize, Default)]
pub struct FlatData {
#[serde(default)]
#[expect(
dead_code,
reason = "version field is consumed by serde but not accessed directly"
)]
pub version: String,
#[serde(default)]
pub actions: HashMap<String, FlatEntryData>,
}
pub fn try_parse(content: &str, path: &Path) -> Result<Option<Lock>, super::Error> {
if content.contains("[resolutions") {
return Ok(None);
}
if !content.contains("[actions") {
return Ok(None);
}
let data: FlatData = super::parse_toml(content, path)?;
Ok(Some(lock_from_flat(data)))
}
fn lock_from_flat(data: FlatData) -> Lock {
let mut entries = HashMap::new();
for (k, entry_data) in data.actions {
let Some(spec) = Spec::parse(&k) else {
continue;
};
let version_str = entry_data
.version
.as_deref()
.unwrap_or(spec.specifier.as_str());
let version = Version::from(version_str);
let ref_type = RefType::parse(&entry_data.ref_type);
entries.insert(
spec,
LockEntry {
reference: ResolvedRef::from_stored(version, ref_type.as_ref()),
commit: Commit {
sha: CommitSha::from(entry_data.sha),
repository: Repository::from(entry_data.repository),
ref_type,
date: CommitDate::from(entry_data.date),
},
},
);
}
Lock::new(entries)
}
#[cfg(test)]
#[expect(
clippy::unwrap_used,
reason = "tests use unwrap, indexing, and other patterns freely"
)]
mod tests {
use super::*;
use crate::domain::action::identity::{ActionId, CommitSha};
use crate::domain::action::spec::Spec;
use crate::domain::action::specifier::Specifier;
fn make_key(action: &str, specifier: &str) -> Spec {
Spec::new(ActionId::from(action), Specifier::parse(specifier))
}
#[test]
fn flat_format_with_version_field_parses() {
let content = r#"version = "1.4"
[actions]
"actions/checkout@^6" = { sha = "de0fac2e4500dabe0009e67214ff5f5447ce83dd", version = "v6.2.3", comment = "v6", repository = "actions/checkout", ref_type = "release", date = "2026-01-09T19:42:23Z" }
"#;
let result = try_parse(content, Path::new("test.lock")).unwrap();
assert!(result.is_some(), "v1.4 format must be parsed as flat");
let lock = result.unwrap();
let entry = lock.get(&make_key("actions/checkout", "^6")).unwrap();
assert_eq!(entry.version_label(), "v6.2.3");
assert_eq!(
entry.commit.sha,
CommitSha::from("de0fac2e4500dabe0009e67214ff5f5447ce83dd")
);
}
#[test]
fn flat_format_without_version_field_parses() {
let content = r#"[actions."actions/checkout@^4"]
sha = "abc123def456789012345678901234567890abcd"
version = "v4.0.0"
comment = "v4"
repository = "actions/checkout"
ref_type = "tag"
date = "2026-01-01T00:00:00Z"
"#;
let result = try_parse(content, Path::new("test.lock")).unwrap();
assert!(result.is_some(), "flat format without version must parse");
let lock = result.unwrap();
let entry = lock.get(&make_key("actions/checkout", "^4")).unwrap();
assert_eq!(entry.version_label(), "v4.0.0");
assert_eq!(
entry.commit.sha,
CommitSha::from("abc123def456789012345678901234567890abcd")
);
}
#[test]
fn flat_entry_missing_version_falls_back_to_specifier() {
let content = r#"[actions."actions/checkout@^4"]
sha = "abc123def456789012345678901234567890abcd"
comment = ""
repository = "actions/checkout"
ref_type = "tag"
date = ""
"#;
let result = try_parse(content, Path::new("test.lock")).unwrap();
assert!(result.is_some());
let lock = result.unwrap();
let entry = lock.get(&make_key("actions/checkout", "^4")).unwrap();
assert_eq!(
entry.version_label(),
"^4",
"missing version must fall back to specifier"
);
}
#[test]
fn two_flat_entries_deduplicating_to_one_action() {
let content = r#"[actions."actions/checkout@^4"]
sha = "abc123def456789012345678901234567890abcd"
version = "v4.2.1"
comment = "v4"
repository = "actions/checkout"
ref_type = "tag"
date = "2026-01-01T00:00:00Z"
[actions."actions/checkout@^4.2"]
sha = "abc123def456789012345678901234567890abcd"
version = "v4.2.1"
comment = "v4.2"
repository = "actions/checkout"
ref_type = "tag"
date = "2026-01-01T00:00:00Z"
"#;
let result = try_parse(content, Path::new("test.lock")).unwrap();
assert!(result.is_some());
let lock = result.unwrap();
let spec1 = make_key("actions/checkout", "^4");
let spec2 = make_key("actions/checkout", "^4.2");
assert!(lock.has(&spec1));
assert!(lock.has(&spec2));
let entry1 = lock.get(&spec1).unwrap();
let entry2 = lock.get(&spec2).unwrap();
assert_eq!(entry1.version_label(), "v4.2.1");
assert_eq!(entry2.version_label(), "v4.2.1");
assert_eq!(entry1.commit.sha, entry2.commit.sha);
}
#[test]
fn try_parse_returns_none_for_two_tier() {
let content = r#"[resolutions."actions/checkout"."^4"]
version = "v4.0.0"
comment = "v4"
[actions."actions/checkout"."v4.0.0"]
sha = "abc123"
repository = "actions/checkout"
ref_type = "tag"
date = ""
"#;
let result = try_parse(content, Path::new("test.lock")).unwrap();
assert!(result.is_none(), "two-tier format should return None");
}
#[test]
fn try_parse_returns_none_for_no_actions() {
let content = "# empty lock file\n";
let result = try_parse(content, Path::new("test.lock")).unwrap();
assert!(
result.is_none(),
"content without [actions] should return None"
);
}
}