use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::PathBuf;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum StalenessReason {
MissingDependency {
name: String,
resource_type: crate::core::ResourceType,
},
VersionChanged {
name: String,
resource_type: crate::core::ResourceType,
old_version: String,
new_version: String,
},
PathChanged {
name: String,
resource_type: crate::core::ResourceType,
old_path: String,
new_path: String,
},
SourceUrlChanged {
name: String,
old_url: String,
new_url: String,
},
DuplicateEntries {
name: String,
resource_type: crate::core::ResourceType,
count: usize,
},
ToolChanged {
name: String,
resource_type: crate::core::ResourceType,
old_tool: String,
new_tool: String,
},
}
impl std::fmt::Display for StalenessReason {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::MissingDependency {
name,
resource_type,
} => {
write!(
f,
"Dependency '{name}' ({resource_type}) is in manifest but missing from lockfile"
)
}
Self::VersionChanged {
name,
resource_type,
old_version,
new_version,
} => {
write!(
f,
"Dependency '{name}' ({resource_type}) version changed from '{old_version}' to '{new_version}'"
)
}
Self::PathChanged {
name,
resource_type,
old_path,
new_path,
} => {
write!(
f,
"Dependency '{name}' ({resource_type}) path changed from '{old_path}' to '{new_path}'"
)
}
Self::SourceUrlChanged {
name,
old_url,
new_url,
} => {
write!(f, "Source repository '{name}' URL changed from '{old_url}' to '{new_url}'")
}
Self::DuplicateEntries {
name,
resource_type,
count,
} => {
write!(
f,
"Found {count} duplicate entries for dependency '{name}' ({resource_type})"
)
}
Self::ToolChanged {
name,
resource_type,
old_tool,
new_tool,
} => {
write!(
f,
"Dependency '{name}' ({resource_type}) tool changed from '{old_tool}' to '{new_tool}'"
)
}
}
}
}
impl std::error::Error for StalenessReason {}
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct ResourceId {
name: String,
source: Option<String>,
tool: Option<String>,
resource_type: crate::core::ResourceType,
variant_inputs_hash: String,
}
impl ResourceId {
pub fn new(
name: impl Into<String>,
source: Option<impl Into<String>>,
tool: Option<impl Into<String>>,
resource_type: crate::core::ResourceType,
variant_inputs_hash: String,
) -> Self {
Self {
name: name.into(),
source: source.map(|s| s.into()),
tool: tool.map(|t| t.into()),
resource_type,
variant_inputs_hash,
}
}
pub fn from_resource(resource: &LockedResource) -> Self {
Self {
name: resource.name.clone(),
source: resource.source.clone(),
tool: resource.tool.clone(),
resource_type: resource.resource_type,
variant_inputs_hash: resource.variant_inputs.hash().to_string(),
}
}
#[must_use]
pub fn name(&self) -> &str {
&self.name
}
#[must_use]
pub fn source(&self) -> Option<&str> {
self.source.as_deref()
}
#[must_use]
pub fn tool(&self) -> Option<&str> {
self.tool.as_deref()
}
#[must_use]
pub fn resource_type(&self) -> crate::core::ResourceType {
self.resource_type
}
#[must_use]
pub fn variant_inputs_hash(&self) -> &str {
&self.variant_inputs_hash
}
}
impl std::fmt::Display for ResourceId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.name)?;
if let Some(ref source) = self.source {
write!(f, " (source: {})", source)?;
}
if let Some(ref tool) = self.tool {
write!(f, " [{}]", tool)?;
}
if !self.variant_inputs_hash.is_empty()
&& self.variant_inputs_hash != crate::utils::EMPTY_VARIANT_INPUTS_HASH.as_str()
{
write!(f, " <hash: {}>", &self.variant_inputs_hash[..16])?;
}
Ok(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LockFile {
pub version: u32,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub sources: Vec<LockedSource>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub agents: Vec<LockedResource>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub snippets: Vec<LockedResource>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub commands: Vec<LockedResource>,
#[serde(default, skip_serializing_if = "Vec::is_empty", rename = "mcp-servers")]
pub mcp_servers: Vec<LockedResource>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub scripts: Vec<LockedResource>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub hooks: Vec<LockedResource>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub skills: Vec<LockedResource>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub manifest_hash: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub has_mutable_deps: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub resource_count: Option<usize>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LockedSource {
pub name: String,
pub url: String,
pub fetched_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct LockedResource {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub source: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
pub path: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub resolved_commit: Option<String>,
pub checksum: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub context_checksum: Option<String>,
pub installed_at: String,
#[serde(default)]
pub dependencies: Vec<String>,
#[serde(skip)]
pub resource_type: crate::core::ResourceType,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub manifest_alias: Option<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub applied_patches: BTreeMap<String, toml::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub install: Option<bool>,
#[serde(
default = "default_variant_inputs_struct",
serialize_with = "serialize_variant_inputs_as_toml",
deserialize_with = "deserialize_variant_inputs_from_toml"
)]
pub variant_inputs: crate::resolver::lockfile_builder::VariantInputs,
#[serde(default, skip_serializing_if = "is_false")]
pub is_private: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub approximate_token_count: Option<u64>,
}
fn is_false(b: &bool) -> bool {
!*b
}
pub struct LockedResourceBuilder {
name: String,
source: Option<String>,
url: Option<String>,
path: String,
version: Option<String>,
resolved_commit: Option<String>,
checksum: String,
installed_at: String,
dependencies: Vec<String>,
resource_type: crate::core::ResourceType,
tool: Option<String>,
manifest_alias: Option<String>,
applied_patches: BTreeMap<String, toml::Value>,
install: Option<bool>,
context_checksum: Option<String>,
variant_inputs: crate::resolver::lockfile_builder::VariantInputs,
is_private: bool,
approximate_token_count: Option<u64>,
}
impl LockedResourceBuilder {
pub fn new(
name: String,
path: String,
checksum: String,
installed_at: String,
resource_type: crate::core::ResourceType,
) -> Self {
Self {
name,
source: None,
url: None,
path,
version: None,
resolved_commit: None,
checksum,
installed_at,
dependencies: Vec::new(),
resource_type,
tool: None,
manifest_alias: None,
applied_patches: BTreeMap::new(),
install: None,
context_checksum: None,
variant_inputs: crate::resolver::lockfile_builder::VariantInputs::default(),
is_private: false,
approximate_token_count: None,
}
}
pub fn source(mut self, source: Option<String>) -> Self {
self.source = source;
self
}
pub fn url(mut self, url: Option<String>) -> Self {
self.url = url;
self
}
pub fn version(mut self, version: Option<String>) -> Self {
self.version = version;
self
}
pub fn resolved_commit(mut self, resolved_commit: Option<String>) -> Self {
self.resolved_commit = resolved_commit;
self
}
pub fn dependencies(mut self, dependencies: Vec<String>) -> Self {
self.dependencies = dependencies;
self
}
pub fn tool(mut self, tool: Option<String>) -> Self {
self.tool = tool;
self
}
pub fn manifest_alias(mut self, manifest_alias: Option<String>) -> Self {
self.manifest_alias = manifest_alias;
self
}
pub fn applied_patches(mut self, applied_patches: BTreeMap<String, toml::Value>) -> Self {
self.applied_patches = applied_patches;
self
}
pub fn install(mut self, install: Option<bool>) -> Self {
self.install = install;
self
}
pub fn context_checksum(mut self, context_checksum: Option<String>) -> Self {
self.context_checksum = context_checksum;
self
}
pub fn variant_inputs(
mut self,
variant_inputs: crate::resolver::lockfile_builder::VariantInputs,
) -> Self {
self.variant_inputs = variant_inputs;
self
}
pub fn is_private(mut self, is_private: bool) -> Self {
self.is_private = is_private;
self
}
pub fn approximate_token_count(mut self, count: Option<u64>) -> Self {
self.approximate_token_count = count;
self
}
pub fn build(self) -> LockedResource {
LockedResource {
name: self.name,
source: self.source,
url: self.url,
path: self.path,
version: self.version,
resolved_commit: self.resolved_commit,
checksum: self.checksum,
context_checksum: self.context_checksum,
installed_at: self.installed_at,
dependencies: self.dependencies,
resource_type: self.resource_type,
tool: self.tool,
manifest_alias: self.manifest_alias,
applied_patches: self.applied_patches,
install: self.install,
variant_inputs: self.variant_inputs,
is_private: self.is_private,
approximate_token_count: self.approximate_token_count,
}
}
}
impl LockedResource {
#[must_use]
pub fn id(&self) -> ResourceId {
ResourceId::from_resource(self)
}
#[must_use]
pub fn matches_id(&self, id: &ResourceId) -> bool {
self.name == id.name
&& self.source == id.source
&& self.tool == id.tool
&& self.variant_inputs.hash() == id.variant_inputs_hash
}
pub fn parsed_dependencies(
&self,
) -> impl Iterator<Item = lockfile_dependency_ref::LockfileDependencyRef> + '_ {
use std::str::FromStr;
self.dependencies.iter().filter_map(|dep_str| {
lockfile_dependency_ref::LockfileDependencyRef::from_str(dep_str)
.map_err(|e| {
tracing::warn!(
"Failed to parse dependency '{}' for resource '{}': {}",
dep_str,
self.name,
e
);
})
.ok()
})
}
#[must_use]
pub fn display_name(&self) -> &str {
self.manifest_alias.as_ref().unwrap_or(&self.name)
}
#[must_use]
pub fn lookup_name(&self) -> &str {
self.manifest_alias.as_ref().unwrap_or(&self.name)
}
#[must_use]
pub fn is_direct_manifest(&self) -> bool {
self.manifest_alias.is_some() && !self.name.starts_with("generated-")
}
#[must_use]
pub fn is_pattern_expanded(&self) -> bool {
self.manifest_alias.is_some()
}
#[must_use]
pub fn is_local(&self) -> bool {
self.resolved_commit.as_deref().is_none_or(str::is_empty)
}
}
mod checksum;
mod helpers;
mod io;
pub mod lockfile_dependency_ref;
pub mod private_lock;
mod resource_ops;
mod validation;
pub use private_lock::PrivateLockFile;
pub mod patch_display;
impl LockFile {
const CURRENT_VERSION: u32 = 1;
#[must_use]
pub const fn new() -> Self {
Self {
version: Self::CURRENT_VERSION,
sources: Vec::new(),
agents: Vec::new(),
snippets: Vec::new(),
commands: Vec::new(),
mcp_servers: Vec::new(),
scripts: Vec::new(),
hooks: Vec::new(),
skills: Vec::new(),
manifest_hash: None,
has_mutable_deps: None,
resource_count: None,
}
}
}
impl LockFile {
pub fn normalize(&self) -> Self {
let mut normalized = self.clone();
Self::normalize_resources(&mut normalized.agents);
Self::normalize_resources(&mut normalized.snippets);
Self::normalize_resources(&mut normalized.commands);
Self::normalize_resources(&mut normalized.scripts);
Self::normalize_resources(&mut normalized.hooks);
Self::normalize_resources(&mut normalized.mcp_servers);
normalized.agents.sort_by(Self::compare_resources);
normalized.snippets.sort_by(Self::compare_resources);
normalized.commands.sort_by(Self::compare_resources);
normalized.scripts.sort_by(Self::compare_resources);
normalized.hooks.sort_by(Self::compare_resources);
normalized.mcp_servers.sort_by(Self::compare_resources);
normalized
}
fn compare_resources(a: &LockedResource, b: &LockedResource) -> std::cmp::Ordering {
a.name
.cmp(&b.name)
.then_with(|| a.source.cmp(&b.source))
.then_with(|| a.tool.cmp(&b.tool))
.then_with(|| a.variant_inputs.hash().cmp(b.variant_inputs.hash()))
}
fn normalize_resources(resources: &mut [LockedResource]) {
use crate::resolver::pattern_expander::generate_dependency_name;
for resource in resources.iter_mut() {
resource.dependencies.sort();
if resource.manifest_alias.is_some() {
continue;
}
let canonical_name = if let Some(source_name) = &resource.source {
let source_context =
crate::resolver::source_context::SourceContext::remote(source_name);
generate_dependency_name(&resource.path, &source_context)
} else {
let path = std::path::Path::new(&resource.path);
if path.is_absolute() {
let without_ext = path.with_extension("");
crate::utils::normalize_path_for_storage(without_ext)
} else {
let source_context =
crate::resolver::source_context::SourceContext::remote("local");
generate_dependency_name(&resource.path, &source_context)
}
};
if resource.name == canonical_name {
continue;
}
resource.manifest_alias = Some(resource.name.clone());
resource.name = canonical_name;
}
}
#[must_use]
pub fn has_valid_fast_path_metadata(&self) -> bool {
self.manifest_hash.is_some()
&& self.has_mutable_deps.is_some()
&& self.resource_count.is_some()
}
#[must_use]
pub fn has_valid_resource_count(&self) -> bool {
match self.resource_count {
None => true, Some(stored_count) => {
let actual_count = self.all_resources().len();
stored_count == actual_count
}
}
}
#[must_use]
pub fn has_valid_manifest_hash_format(&self) -> bool {
match &self.manifest_hash {
None => true,
Some(hash) => {
if let Some(hex_part) = hash.strip_prefix("sha256:") {
hex_part.len() == 64 && hex_part.chars().all(|c| c.is_ascii_hexdigit())
} else {
false
}
}
}
}
#[must_use]
pub fn split_by_privacy(&self) -> (Self, PrivateLockFile) {
let mut public_lock = Self::new();
let mut private_resources: Vec<LockedResource> = Vec::new();
public_lock.manifest_hash = self.manifest_hash.clone();
public_lock.has_mutable_deps = self.has_mutable_deps;
public_lock.sources = self.sources.clone();
for resource in &self.agents {
if resource.is_private {
private_resources.push(resource.clone());
} else {
public_lock.agents.push(resource.clone());
}
}
for resource in &self.snippets {
if resource.is_private {
private_resources.push(resource.clone());
} else {
public_lock.snippets.push(resource.clone());
}
}
for resource in &self.commands {
if resource.is_private {
private_resources.push(resource.clone());
} else {
public_lock.commands.push(resource.clone());
}
}
for resource in &self.scripts {
if resource.is_private {
private_resources.push(resource.clone());
} else {
public_lock.scripts.push(resource.clone());
}
}
for resource in &self.mcp_servers {
if resource.is_private {
private_resources.push(resource.clone());
} else {
public_lock.mcp_servers.push(resource.clone());
}
}
for resource in &self.hooks {
if resource.is_private {
private_resources.push(resource.clone());
} else {
public_lock.hooks.push(resource.clone());
}
}
for resource in &self.skills {
if resource.is_private {
private_resources.push(resource.clone());
} else {
public_lock.skills.push(resource.clone());
}
}
public_lock.resource_count = Some(public_lock.all_resources().len());
let private_lock = PrivateLockFile::from_resources(private_resources);
(public_lock, private_lock)
}
pub fn merge_private(&mut self, private_lock: &PrivateLockFile) {
self.agents.extend(private_lock.agents.iter().cloned());
self.snippets.extend(private_lock.snippets.iter().cloned());
self.commands.extend(private_lock.commands.iter().cloned());
self.scripts.extend(private_lock.scripts.iter().cloned());
self.mcp_servers.extend(private_lock.mcp_servers.iter().cloned());
self.hooks.extend(private_lock.hooks.iter().cloned());
if self.resource_count.is_some() {
self.resource_count = Some(self.all_resources().len());
}
}
}
impl Default for LockFile {
fn default() -> Self {
Self::new()
}
}
fn default_variant_inputs_struct() -> crate::resolver::lockfile_builder::VariantInputs {
crate::resolver::lockfile_builder::VariantInputs::new(serde_json::Value::Object(
serde_json::Map::new(),
))
}
fn serialize_variant_inputs_as_toml<S>(
variant_inputs: &crate::resolver::lockfile_builder::VariantInputs,
serializer: S,
) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
use crate::lockfile::patch_display::json_to_toml_value;
let toml_value = json_to_toml_value(variant_inputs.json()).map_err(|e| {
serde::ser::Error::custom(format!("Failed to convert variant_inputs to TOML: {}", e))
})?;
toml_value.serialize(serializer)
}
fn deserialize_variant_inputs_from_toml<'de, D>(
deserializer: D,
) -> Result<crate::resolver::lockfile_builder::VariantInputs, D::Error>
where
D: serde::Deserializer<'de>,
{
use crate::manifest::patches::toml_value_to_json;
let toml_value = toml::Value::deserialize(deserializer)?;
let json_value = toml_value_to_json(&toml_value).map_err(|e| {
serde::de::Error::custom(format!("Failed to convert TOML to variant_inputs: {}", e))
})?;
Ok(crate::resolver::lockfile_builder::VariantInputs::new(json_value))
}
#[must_use]
pub fn find_lockfile() -> Option<PathBuf> {
let mut current = std::env::current_dir().ok()?;
loop {
let lockfile_path = current.join("agpm.lock");
if lockfile_path.exists() {
return Some(lockfile_path);
}
if !current.pop() {
return None;
}
}
}