use std::collections::BTreeMap;
use std::fmt;
use std::str::FromStr;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use thiserror::Error;
use uuid::Uuid;
pub const PROJECT_MANIFEST_SCHEMA_VERSION: u32 = 3;
pub const PROJECT_INDEX_SCHEMA_VERSION: u32 = 3;
fn initial_project_revision() -> u64 {
1
}
#[derive(Debug, Clone, PartialEq, Eq, Error)]
#[error("invalid project id: {0}")]
pub struct InvalidProjectId(pub String);
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct ProjectId(String);
impl ProjectId {
pub const MAX_LEN: usize = 64;
pub fn new() -> Self {
Self(Uuid::new_v4().to_string())
}
pub fn parse(value: impl Into<String>) -> Result<Self, InvalidProjectId> {
let value = value.into();
let valid = !value.is_empty()
&& value.len() <= Self::MAX_LEN
&& value
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_');
if valid {
Ok(Self(value))
} else {
Err(InvalidProjectId(value))
}
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn into_string(self) -> String {
self.0
}
}
impl Default for ProjectId {
fn default() -> Self {
Self::new()
}
}
impl fmt::Display for ProjectId {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.0)
}
}
impl AsRef<str> for ProjectId {
fn as_ref(&self) -> &str {
self.as_str()
}
}
impl FromStr for ProjectId {
type Err = InvalidProjectId;
fn from_str(value: &str) -> Result<Self, Self::Err> {
Self::parse(value)
}
}
impl TryFrom<String> for ProjectId {
type Error = InvalidProjectId;
fn try_from(value: String) -> Result<Self, Self::Error> {
Self::parse(value)
}
}
impl From<ProjectId> for String {
fn from(value: ProjectId) -> Self {
value.into_string()
}
}
impl Serialize for ProjectId {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(self.as_str())
}
}
impl<'de> Deserialize<'de> for ProjectId {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = String::deserialize(deserializer)?;
Self::parse(value).map_err(serde::de::Error::custom)
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ProjectStatus {
#[default]
Active,
Archived,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ProjectPathStatus {
Configured,
NeedsSelection,
#[default]
NeedsConfiguration,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorkspaceBinding {
pub path: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub git_common_dir: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProjectSidebarSection {
pub id: String,
pub name: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProjectSidebar {
pub sections: Vec<ProjectSidebarSection>,
pub session_sections: BTreeMap<String, Option<String>>,
pub hidden_legacy: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProjectManifest {
pub schema_version: u32,
pub id: ProjectId,
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub section: Option<String>,
#[serde(default)]
pub sidebar: Option<ProjectSidebar>,
#[serde(default)]
pub status: ProjectStatus,
#[serde(default)]
pub project_path: Option<String>,
#[serde(default)]
pub project_path_status: ProjectPathStatus,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub workspace_bindings: Vec<WorkspaceBinding>,
#[serde(default = "initial_project_revision")]
pub revision: u64,
#[serde(default = "initial_project_revision")]
pub resource_revision: u64,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
impl ProjectManifest {
pub fn new(
id: ProjectId,
name: impl Into<String>,
description: Option<String>,
now: DateTime<Utc>,
) -> Self {
Self {
schema_version: PROJECT_MANIFEST_SCHEMA_VERSION,
id,
name: name.into(),
description,
section: None,
sidebar: None,
status: ProjectStatus::Active,
project_path: None,
project_path_status: ProjectPathStatus::NeedsConfiguration,
workspace_bindings: Vec::new(),
revision: 1,
resource_revision: 1,
created_at: now,
updated_at: now,
}
}
pub fn workspace_roots(&self) -> impl Iterator<Item = &str> {
self.project_path.iter().map(String::as_str).chain(
self.workspace_bindings
.iter()
.map(|binding| binding.path.as_str()),
)
}
pub fn workspace_count(&self) -> usize {
usize::from(self.project_path.is_some()) + self.workspace_bindings.len()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProjectIndexEntry {
pub id: ProjectId,
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub section: Option<String>,
#[serde(default)]
pub sidebar: Option<ProjectSidebar>,
pub status: ProjectStatus,
#[serde(default)]
pub project_path: Option<String>,
#[serde(default)]
pub project_path_status: ProjectPathStatus,
pub revision: u64,
pub resource_revision: u64,
pub workspace_count: usize,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
impl From<&ProjectManifest> for ProjectIndexEntry {
fn from(manifest: &ProjectManifest) -> Self {
Self {
id: manifest.id.clone(),
name: manifest.name.clone(),
description: manifest.description.clone(),
section: manifest.section.clone(),
sidebar: manifest.sidebar.clone(),
status: manifest.status,
project_path: manifest.project_path.clone(),
project_path_status: manifest.project_path_status,
revision: manifest.revision,
resource_revision: manifest.resource_revision,
workspace_count: manifest.workspace_count(),
created_at: manifest.created_at,
updated_at: manifest.updated_at,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProjectIndex {
pub schema_version: u32,
pub revision: u64,
pub updated_at: DateTime<Utc>,
pub projects: BTreeMap<ProjectId, ProjectIndexEntry>,
}
impl ProjectIndex {
pub fn empty(now: DateTime<Utc>) -> Self {
Self {
schema_version: PROJECT_INDEX_SCHEMA_VERSION,
revision: 0,
updated_at: now,
projects: BTreeMap::new(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ProjectResourceKind {
Settings,
Skills,
Commands,
Memory,
Artifacts,
State,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProjectResourceEntry {
pub kind: ProjectResourceKind,
pub present: bool,
pub item_count: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProjectResourceSummary {
pub project_id: ProjectId,
pub resource_revision: u64,
pub resources: Vec<ProjectResourceEntry>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LegacySessionProjectInput {
pub session_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workspace_path: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub canonical_path: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub git_common_dir: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum LegacyProjectMatchBasis {
ExactCanonicalBinding,
GitCommonDir,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LegacyProjectAssignment {
pub session_id: String,
pub project_id: ProjectId,
pub basis: LegacyProjectMatchBasis,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LegacyProjectSuggestion {
pub basis: LegacyProjectMatchBasis,
pub session_ids: Vec<String>,
pub workspace_paths: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LegacyProjectUnassigned {
pub session_id: String,
pub reason: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct LegacyProjectDryRunReport {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub assignments: Vec<LegacyProjectAssignment>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub suggestions: Vec<LegacyProjectSuggestion>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub unassigned: Vec<LegacyProjectUnassigned>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub diagnostics: Vec<String>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn project_id_is_opaque_and_path_safe() {
for valid in ["01JABCDEF0123456789ABCDEFG", "uuid-like_value-1"] {
let id: ProjectId = valid.parse().unwrap();
assert_eq!(id.as_str(), valid);
let encoded = serde_json::to_string(&id).unwrap();
assert_eq!(serde_json::from_str::<ProjectId>(&encoded).unwrap(), id);
}
for invalid in ["", ".", "..", "../escape", "a/b", r"a\b", "with space"] {
assert!(invalid.parse::<ProjectId>().is_err(), "{invalid}");
}
}
#[test]
fn manifest_additive_fields_have_safe_defaults() {
let value = serde_json::json!({
"schema_version": 1,
"id": "01JABCDEF0123456789ABCDEFG",
"name": "Zenith",
"created_at": "2026-01-01T00:00:00Z",
"updated_at": "2026-01-01T00:00:00Z"
});
let manifest: ProjectManifest = serde_json::from_value(value).unwrap();
assert_eq!(manifest.status, ProjectStatus::Active);
assert!(manifest.section.is_none());
assert!(manifest.sidebar.is_none());
assert!(manifest.project_path.is_none());
assert_eq!(
manifest.project_path_status,
ProjectPathStatus::NeedsConfiguration
);
assert!(manifest.workspace_bindings.is_empty());
assert_eq!(manifest.revision, 1);
assert_eq!(manifest.resource_revision, 1);
assert_eq!(
serde_json::to_value(&manifest).unwrap().get("sidebar"),
Some(&serde_json::Value::Null)
);
assert_eq!(
serde_json::to_value(ProjectIndexEntry::from(&manifest))
.unwrap()
.get("sidebar"),
Some(&serde_json::Value::Null)
);
}
#[test]
fn sidebar_public_serde_roundtrip_preserves_empty_buckets_and_explicit_clearing() {
let sidebar = ProjectSidebar {
sections: vec![ProjectSidebarSection {
id: "section:2d567c47-a081-4a8d-9c9e-246bce470c9f".into(),
name: "Support".into(),
}],
session_sections: BTreeMap::from([("root".into(), None)]),
hidden_legacy: true,
};
let encoded = serde_json::to_value(&sidebar).unwrap();
assert!(encoded["session_sections"]["root"].is_null());
assert_eq!(
serde_json::from_value::<ProjectSidebar>(encoded).unwrap(),
sidebar
);
}
}