use std::collections::{BTreeMap, BTreeSet};
use std::fs;
use std::fs::OpenOptions;
use std::io::ErrorKind;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
use crate::graph::tree::ArtifactKind;
use crate::core::error::SpecmanError;
use crate::metadata::frontmatter::{
ArtifactFrontMatter, ImplementationFrontMatter, ScratchFrontMatter, SpecificationFrontMatter,
};
use crate::workspace::{normalize_workspace_path, WorkspacePaths};
use super::index::{
ArtifactKey, ArtifactRecord, ConstraintIdentifier, ConstraintRecord, HeadingIdentifier,
HeadingRecord, RelationshipEdge, RelationshipKind, WORKSPACE_INDEX_SCHEMA_VERSION,
WorkspaceIndex,
};
const INDEX_DIR_NAME: &str = "index";
const LOCK_FILE_NAME: &str = ".lock";
const MANIFEST_FILE_NAME: &str = "manifest.json";
fn index_file_name() -> String {
format!("index.v{}.json", WORKSPACE_INDEX_SCHEMA_VERSION)
}
#[derive(Clone, Debug)]
pub struct IndexCache {
root: PathBuf,
}
impl IndexCache {
pub fn new(workspace: &WorkspacePaths) -> Self {
Self {
root: workspace.dot_specman().join("cache").join(INDEX_DIR_NAME),
}
}
pub fn root(&self) -> &Path {
&self.root
}
pub fn purge(&self) -> Result<(), SpecmanError> {
if self.root.is_dir() {
fs::remove_dir_all(&self.root)?;
}
Ok(())
}
pub fn load_if_fresh(
&self,
workspace: &WorkspacePaths,
canonical_spec_impl: &[(ArtifactKind, PathBuf)],
) -> Result<Option<(WorkspaceIndex, Vec<UnresolvedHeadingRef>)>, SpecmanError> {
self.fail_fast_if_locked()?;
let manifest = match self.read_manifest()? {
Some(m) => m,
None => return Ok(None),
};
if manifest.schema_version != WORKSPACE_INDEX_SCHEMA_VERSION {
return Ok(None);
}
let workspace_fingerprint = root_fingerprint(workspace)?;
if manifest.workspace_root_fingerprint != workspace_fingerprint {
return Ok(None);
}
if !self.is_fresh(&manifest, workspace, canonical_spec_impl)? {
return Ok(None);
}
let index_path = self.root.join(&manifest.index_file);
if !index_path.is_file() {
return Ok(None);
}
let content = fs::read_to_string(&index_path)?;
let persisted: PersistedWorkspaceIndex = serde_json::from_str(&content).map_err(|err| {
SpecmanError::Serialization(format!(
"invalid cached workspace index {}: {err}",
index_path.display()
))
})?;
if persisted.schema_version != WORKSPACE_INDEX_SCHEMA_VERSION {
return Ok(None);
}
let unresolved = persisted
.unresolved_heading_refs
.clone()
.into_iter()
.map(UnresolvedHeadingRef::from)
.collect();
let index = persisted.rehydrate(workspace)?;
Ok(Some((index, unresolved)))
}
pub fn save(
&self,
workspace: &WorkspacePaths,
canonical_spec_impl: &[(ArtifactKind, PathBuf)],
index: &WorkspaceIndex,
unresolved_refs: &[UnresolvedHeadingRef],
) -> Result<(), SpecmanError> {
let lock = IndexCacheLock::acquire(&self.root)?;
fs::create_dir_all(&self.root).map_err(|err| {
SpecmanError::Workspace(format!(
"failed to prepare index cache directory {}: {err}",
self.root.display()
))
})?;
let manifest = build_manifest_for_save(workspace, canonical_spec_impl, &index_file_name())?;
let persisted = PersistedWorkspaceIndex::from_index(index, unresolved_refs);
let index_path = self.root.join(&manifest.index_file);
let manifest_path = self.root.join(MANIFEST_FILE_NAME);
write_atomic_json(&index_path, &persisted)?;
write_atomic_json(&manifest_path, &manifest)?;
drop(lock);
Ok(())
}
fn is_fresh(
&self,
manifest: &IndexManifest,
workspace: &WorkspacePaths,
canonical_spec_impl: &[(ArtifactKind, PathBuf)],
) -> Result<bool, SpecmanError> {
let expected: BTreeSet<ManifestArtifactEntry> = canonical_spec_impl
.iter()
.map(|(kind, path)| ManifestArtifactEntry::from_path(*kind, path, workspace))
.collect::<Result<_, _>>()?;
let actual: BTreeSet<ManifestArtifactEntry> = manifest.artifacts.iter().cloned().collect();
Ok(expected == actual)
}
fn read_manifest(&self) -> Result<Option<IndexManifest>, SpecmanError> {
let manifest_path = self.root.join(MANIFEST_FILE_NAME);
if !manifest_path.is_file() {
return Ok(None);
}
let content = fs::read_to_string(&manifest_path)?;
let manifest: IndexManifest = serde_json::from_str(&content).map_err(|err| {
SpecmanError::Serialization(format!(
"invalid index cache manifest {}: {err}",
manifest_path.display()
))
})?;
Ok(Some(manifest))
}
fn fail_fast_if_locked(&self) -> Result<(), SpecmanError> {
let lock_path = self.root.join(LOCK_FILE_NAME);
if lock_path.exists() {
return Err(SpecmanError::Workspace(format!(
"workspace index cache is locked by another process: {}",
lock_path.display()
)));
}
Ok(())
}
}
struct IndexCacheLock {
path: PathBuf,
}
impl IndexCacheLock {
fn acquire(dir: &Path) -> Result<Self, SpecmanError> {
fs::create_dir_all(dir).map_err(|err| {
SpecmanError::Workspace(format!(
"failed to prepare index cache directory {}: {err}",
dir.display()
))
})?;
let lock_path = dir.join(LOCK_FILE_NAME);
match OpenOptions::new()
.write(true)
.create_new(true)
.open(&lock_path)
{
Ok(_) => Ok(Self { path: lock_path }),
Err(err) if err.kind() == ErrorKind::AlreadyExists => {
Err(SpecmanError::Workspace(format!(
"workspace index cache is locked by another process: {}",
lock_path.display()
)))
}
Err(err) => Err(SpecmanError::Workspace(format!(
"failed to create index cache lock {}: {err}",
lock_path.display()
))),
}
}
}
impl Drop for IndexCacheLock {
fn drop(&mut self) {
let _ = fs::remove_file(&self.path);
}
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
struct ManifestArtifactEntry {
workspace_path: String,
kind: ArtifactKind,
mtime_unix_ms: u64,
size: u64,
}
impl ManifestArtifactEntry {
fn from_path(
kind: ArtifactKind,
canonical_path: &Path,
workspace: &WorkspacePaths,
) -> Result<Self, SpecmanError> {
if !canonical_path.starts_with(workspace.root()) {
return Err(SpecmanError::Workspace(format!(
"artifact {} escapes workspace {}",
canonical_path.display(),
workspace.root().display()
)));
}
let workspace_path = canonical_path
.strip_prefix(workspace.root())
.map_err(|_| {
SpecmanError::Workspace(format!(
"unable to compute workspace-relative path for {}",
canonical_path.display()
))
})?
.to_string_lossy()
.replace('\\', "/");
let metadata = fs::metadata(canonical_path)?;
let size = metadata.len();
let modified = metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH);
let mtime_unix_ms = system_time_to_unix_ms(modified);
Ok(Self {
workspace_path,
kind,
mtime_unix_ms,
size,
})
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
struct IndexManifest {
schema_version: u32,
workspace_root_fingerprint: String,
generated_at_unix_ms: u64,
index_file: String,
artifacts: Vec<ManifestArtifactEntry>,
}
fn build_manifest(
fingerprint: String,
workspace: &WorkspacePaths,
canonical_spec_impl: &[(ArtifactKind, PathBuf)],
index_file: &str,
) -> Result<IndexManifest, SpecmanError> {
let mut artifacts: Vec<ManifestArtifactEntry> = canonical_spec_impl
.iter()
.map(|(kind, path)| ManifestArtifactEntry::from_path(*kind, path, workspace))
.collect::<Result<_, _>>()?;
artifacts.sort();
Ok(IndexManifest {
schema_version: WORKSPACE_INDEX_SCHEMA_VERSION,
workspace_root_fingerprint: fingerprint,
generated_at_unix_ms: now_unix_ms(),
index_file: index_file.to_string(),
artifacts,
})
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(tag = "kind", content = "value")]
enum PersistedFrontMatter {
Specification(SpecificationFrontMatter),
Implementation(ImplementationFrontMatter),
Scratch(ScratchFrontMatter),
}
impl From<&ArtifactFrontMatter> for PersistedFrontMatter {
fn from(value: &ArtifactFrontMatter) -> Self {
match value {
ArtifactFrontMatter::Specification(v) => Self::Specification(v.clone()),
ArtifactFrontMatter::Implementation(v) => Self::Implementation(v.clone()),
ArtifactFrontMatter::Scratch(v) => Self::Scratch(v.clone()),
}
}
}
impl From<PersistedFrontMatter> for ArtifactFrontMatter {
fn from(value: PersistedFrontMatter) -> Self {
match value {
PersistedFrontMatter::Specification(v) => ArtifactFrontMatter::Specification(v),
PersistedFrontMatter::Implementation(v) => ArtifactFrontMatter::Implementation(v),
PersistedFrontMatter::Scratch(v) => ArtifactFrontMatter::Scratch(v),
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash)]
struct PersistedArtifactKey {
kind: ArtifactKind,
workspace_path: String,
}
impl From<&ArtifactKey> for PersistedArtifactKey {
fn from(value: &ArtifactKey) -> Self {
Self {
kind: value.kind,
workspace_path: value.workspace_path.clone(),
}
}
}
impl From<PersistedArtifactKey> for ArtifactKey {
fn from(value: PersistedArtifactKey) -> Self {
Self {
kind: value.kind,
workspace_path: value.workspace_path,
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
struct PersistedArtifactRecord {
key: PersistedArtifactKey,
front_matter: Option<PersistedFrontMatter>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash)]
struct PersistedHeadingIdentifier {
artifact: PersistedArtifactKey,
slug: String,
}
impl From<&HeadingIdentifier> for PersistedHeadingIdentifier {
fn from(value: &HeadingIdentifier) -> Self {
Self {
artifact: PersistedArtifactKey::from(&value.artifact),
slug: value.slug.clone(),
}
}
}
impl From<PersistedHeadingIdentifier> for HeadingIdentifier {
fn from(value: PersistedHeadingIdentifier) -> Self {
Self {
artifact: ArtifactKey::from(value.artifact),
slug: value.slug,
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
struct PersistedHeadingRecord {
id: PersistedHeadingIdentifier,
level: u8,
title: String,
order: usize,
parent: Option<PersistedHeadingIdentifier>,
children: Vec<PersistedHeadingIdentifier>,
content: String,
referenced_headings: Vec<PersistedHeadingIdentifier>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash)]
struct PersistedConstraintIdentifier {
artifact: PersistedArtifactKey,
group: String,
}
impl From<&ConstraintIdentifier> for PersistedConstraintIdentifier {
fn from(value: &ConstraintIdentifier) -> Self {
Self {
artifact: PersistedArtifactKey::from(&value.artifact),
group: value.group.clone(),
}
}
}
impl From<PersistedConstraintIdentifier> for ConstraintIdentifier {
fn from(value: PersistedConstraintIdentifier) -> Self {
Self {
artifact: ArtifactKey::from(value.artifact),
group: value.group,
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
struct PersistedConstraintRecord {
id: PersistedConstraintIdentifier,
heading: PersistedHeadingIdentifier,
line: usize,
referenced_headings: Vec<PersistedHeadingIdentifier>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
struct PersistedRelationshipEdge {
kind: RelationshipKind,
from: String,
to: String,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
struct PersistedWorkspaceIndex {
schema_version: u32,
artifacts: Vec<PersistedArtifactRecord>,
headings: Vec<PersistedHeadingRecord>,
constraints: Vec<PersistedConstraintRecord>,
relationships: Vec<PersistedRelationshipEdge>,
unresolved_heading_refs: Vec<PersistedUnresolvedHeadingRef>,
}
impl PersistedWorkspaceIndex {
fn from_index(index: &WorkspaceIndex, unresolved_refs: &[UnresolvedHeadingRef]) -> Self {
let mut artifacts: Vec<PersistedArtifactRecord> = index
.artifacts
.values()
.filter(|record| record.key.kind != ArtifactKind::ScratchPad)
.map(|record| PersistedArtifactRecord {
key: PersistedArtifactKey::from(&record.key),
front_matter: record.front_matter.as_ref().map(PersistedFrontMatter::from),
})
.collect();
artifacts.sort_by(|a, b| a.key.cmp(&b.key));
let mut derived_unresolved: Vec<UnresolvedHeadingRef> = Vec::new();
let mut headings: Vec<PersistedHeadingRecord> = index
.headings
.values()
.filter(|record| record.id.artifact.kind != ArtifactKind::ScratchPad)
.map(|record| PersistedHeadingRecord {
id: PersistedHeadingIdentifier::from(&record.id),
level: record.level,
title: record.title.clone(),
order: record.order,
parent: record.parent.as_ref().map(PersistedHeadingIdentifier::from),
children: record
.children
.iter()
.map(PersistedHeadingIdentifier::from)
.collect(),
content: record.content.clone(),
referenced_headings: record
.referenced_headings
.iter()
.filter(|id| id.artifact.kind != ArtifactKind::ScratchPad)
.map(PersistedHeadingIdentifier::from)
.collect(),
})
.collect();
headings.sort_by(|a, b| a.id.cmp(&b.id));
for record in index.headings.values() {
if record.id.artifact.kind == ArtifactKind::ScratchPad {
continue;
}
for target in &record.referenced_headings {
if target.artifact.kind != ArtifactKind::ScratchPad {
continue;
}
derived_unresolved.push(UnresolvedHeadingRef {
from: record.id.clone(),
target: UnresolvedTarget::InterDoc {
workspace_path: target.artifact.workspace_path.clone(),
slug: target.slug.clone(),
},
});
}
}
let mut constraints: Vec<PersistedConstraintRecord> = index
.constraints
.values()
.filter(|record| record.id.artifact.kind != ArtifactKind::ScratchPad)
.map(|record| PersistedConstraintRecord {
id: PersistedConstraintIdentifier::from(&record.id),
heading: PersistedHeadingIdentifier::from(&record.heading),
line: record.line,
referenced_headings: record
.referenced_headings
.iter()
.filter(|id| id.artifact.kind != ArtifactKind::ScratchPad)
.map(PersistedHeadingIdentifier::from)
.collect(),
})
.collect();
constraints.sort_by(|a, b| a.id.cmp(&b.id));
let relationships: Vec<PersistedRelationshipEdge> = index
.relationships
.iter()
.filter(|edge| {
!edge.from.starts_with(".specman/scratchpad/")
&& !edge.to.starts_with(".specman/scratchpad/")
})
.map(|edge| PersistedRelationshipEdge {
kind: edge.kind,
from: edge.from.clone(),
to: edge.to.clone(),
})
.collect();
let mut all_unresolved: Vec<UnresolvedHeadingRef> = unresolved_refs.to_vec();
all_unresolved.extend(derived_unresolved);
let unresolved_heading_refs = all_unresolved
.iter()
.map(PersistedUnresolvedHeadingRef::from)
.collect();
Self {
schema_version: WORKSPACE_INDEX_SCHEMA_VERSION,
artifacts,
headings,
constraints,
relationships,
unresolved_heading_refs,
}
}
fn rehydrate(self, workspace: &WorkspacePaths) -> Result<WorkspaceIndex, SpecmanError> {
let mut index = WorkspaceIndex {
schema_version: self.schema_version,
workspace_root: workspace.root().to_path_buf(),
..Default::default()
};
for record in self.artifacts {
let key: ArtifactKey = record.key.into();
let absolute_path = workspace.root().join(&key.workspace_path);
let normalized = normalize_workspace_path(&absolute_path);
let normalized_root = normalize_workspace_path(workspace.root());
if !normalized.starts_with(workspace.root()) && !normalized.starts_with(&normalized_root) {
return Err(SpecmanError::Workspace(format!(
"cached artifact {} escapes workspace {}",
normalized.display(),
workspace.root().display()
)));
}
let front_matter = record.front_matter.map(ArtifactFrontMatter::from);
index.artifacts.insert(
key.clone(),
ArtifactRecord {
key,
absolute_path: normalized,
front_matter,
},
);
}
for record in self.headings {
let id: HeadingIdentifier = record.id.into();
index.headings.insert(
id.clone(),
HeadingRecord {
id,
level: record.level,
title: record.title,
order: record.order,
parent: record.parent.map(HeadingIdentifier::from),
children: record
.children
.into_iter()
.map(HeadingIdentifier::from)
.collect(),
content: record.content,
referenced_headings: record
.referenced_headings
.into_iter()
.map(HeadingIdentifier::from)
.collect(),
},
);
}
for record in self.constraints {
let id: ConstraintIdentifier = record.id.into();
index.constraints.insert(
id.clone(),
ConstraintRecord {
id,
heading: HeadingIdentifier::from(record.heading),
line: record.line,
referenced_headings: record
.referenced_headings
.into_iter()
.map(HeadingIdentifier::from)
.collect(),
},
);
}
index.relationships = self
.relationships
.into_iter()
.map(|edge| RelationshipEdge {
kind: edge.kind,
from: edge.from,
to: edge.to,
})
.collect();
Ok(index)
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct UnresolvedHeadingRef {
pub from: HeadingIdentifier,
pub target: UnresolvedTarget,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum UnresolvedTarget {
IntraDoc {
slug: String,
},
InterDoc {
workspace_path: String,
slug: String,
},
File {
workspace_path: String,
},
}
#[derive(Clone, Debug, Serialize, Deserialize)]
struct PersistedUnresolvedHeadingRef {
from: PersistedHeadingIdentifier,
target: PersistedUnresolvedTarget,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(tag = "kind", content = "value")]
enum PersistedUnresolvedTarget {
IntraDoc {
slug: String,
},
InterDoc {
workspace_path: String,
slug: String,
},
File {
workspace_path: String,
},
}
impl From<&UnresolvedHeadingRef> for PersistedUnresolvedHeadingRef {
fn from(value: &UnresolvedHeadingRef) -> Self {
Self {
from: PersistedHeadingIdentifier::from(&value.from),
target: match &value.target {
UnresolvedTarget::IntraDoc { slug } => {
PersistedUnresolvedTarget::IntraDoc { slug: slug.clone() }
}
UnresolvedTarget::InterDoc {
workspace_path,
slug,
} => PersistedUnresolvedTarget::InterDoc {
workspace_path: workspace_path.clone(),
slug: slug.clone(),
},
UnresolvedTarget::File { workspace_path } => PersistedUnresolvedTarget::File {
workspace_path: workspace_path.clone(),
},
},
}
}
}
impl From<PersistedUnresolvedHeadingRef> for UnresolvedHeadingRef {
fn from(value: PersistedUnresolvedHeadingRef) -> Self {
Self {
from: HeadingIdentifier::from(value.from),
target: match value.target {
PersistedUnresolvedTarget::IntraDoc { slug } => UnresolvedTarget::IntraDoc { slug },
PersistedUnresolvedTarget::InterDoc {
workspace_path,
slug,
} => UnresolvedTarget::InterDoc {
workspace_path,
slug,
},
PersistedUnresolvedTarget::File { workspace_path } => {
UnresolvedTarget::File { workspace_path }
}
},
}
}
}
fn write_atomic_json<T: Serialize>(path: &Path, value: &T) -> Result<(), SpecmanError> {
let tmp_path = path.with_extension("tmp");
let payload = serde_json::to_string_pretty(value)?;
fs::write(&tmp_path, payload).map_err(|err| {
let _ = fs::remove_file(&tmp_path);
SpecmanError::Workspace(format!(
"failed to write temporary cache file {}: {err}",
tmp_path.display()
))
})?;
if path.is_file() {
fs::remove_file(path).map_err(|err| {
let _ = fs::remove_file(&tmp_path);
SpecmanError::Workspace(format!(
"failed to remove previous cache file {}: {err}",
path.display()
))
})?;
}
fs::rename(&tmp_path, path).map_err(|err| {
let _ = fs::remove_file(&tmp_path);
SpecmanError::Workspace(format!(
"failed to publish cache file {}: {err}",
path.display()
))
})
}
fn system_time_to_unix_ms(t: SystemTime) -> u64 {
t.duration_since(UNIX_EPOCH).unwrap_or_default().as_millis() as u64
}
fn now_unix_ms() -> u64 {
system_time_to_unix_ms(SystemTime::now())
}
fn root_fingerprint(workspace: &WorkspacePaths) -> Result<String, SpecmanError> {
let path = workspace.dot_specman().join("root_fingerprint");
if path.is_file() {
let content = fs::read_to_string(&path)?;
let trimmed = content.trim();
if trimmed.is_empty() {
return Err(SpecmanError::Workspace(format!(
"workspace root fingerprint {} is empty",
path.display()
)));
}
return Ok(trimmed.to_string());
}
fs::create_dir_all(workspace.dot_specman()).map_err(|err| {
SpecmanError::Workspace(format!(
"failed to prepare workspace dot directory {}: {err}",
workspace.dot_specman().display()
))
})?;
let uuid = generate_uuid_v4();
fs::write(&path, format!("{uuid}\n")).map_err(|err| {
SpecmanError::Workspace(format!(
"failed to write workspace root fingerprint {}: {err}",
path.display()
))
})?;
Ok(uuid)
}
fn generate_uuid_v4() -> String {
let mut bytes = [0u8; 16];
if getrandom::getrandom(&mut bytes).is_err() {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
for (idx, b) in bytes.iter_mut().enumerate() {
*b = ((now >> (idx * 8)) & 0xFF) as u8;
}
}
bytes[6] = (bytes[6] & 0x0F) | 0x40;
bytes[8] = (bytes[8] & 0x3F) | 0x80;
format!(
"{:02x}{:02x}{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}",
bytes[0],
bytes[1],
bytes[2],
bytes[3],
bytes[4],
bytes[5],
bytes[6],
bytes[7],
bytes[8],
bytes[9],
bytes[10],
bytes[11],
bytes[12],
bytes[13],
bytes[14],
bytes[15]
)
}
pub(crate) fn resolve_unresolved_refs(
index: &mut WorkspaceIndex,
unresolved: Vec<UnresolvedHeadingRef>,
) {
let mut artifact_by_path: BTreeMap<String, ArtifactKey> = BTreeMap::new();
for key in index.artifacts.keys() {
artifact_by_path.insert(key.workspace_path.clone(), key.clone());
}
for pending in unresolved {
match pending.target {
UnresolvedTarget::IntraDoc { slug } => {
let to = HeadingIdentifier {
artifact: pending.from.artifact.clone(),
slug,
};
if index.headings.contains_key(&to) {
attach_heading_reference(index, &pending.from, &to);
index.relationships.push(RelationshipEdge {
kind: RelationshipKind::HeadingToHeading,
from: heading_ref_string(&pending.from),
to: heading_ref_string(&to),
});
}
}
UnresolvedTarget::InterDoc {
workspace_path,
slug,
} => {
let Some(artifact_key) = artifact_by_path.get(&workspace_path).cloned() else {
continue;
};
let to = HeadingIdentifier {
artifact: artifact_key,
slug,
};
if index.headings.contains_key(&to) {
attach_heading_reference(index, &pending.from, &to);
index.relationships.push(RelationshipEdge {
kind: RelationshipKind::HeadingToHeading,
from: heading_ref_string(&pending.from),
to: heading_ref_string(&to),
});
}
}
UnresolvedTarget::File { workspace_path } => {
index.relationships.push(RelationshipEdge {
kind: RelationshipKind::HeadingToFile,
from: heading_ref_string(&pending.from),
to: workspace_path,
});
}
}
}
}
fn attach_heading_reference(
index: &mut WorkspaceIndex,
from: &HeadingIdentifier,
to: &HeadingIdentifier,
) {
if let Some(from_record) = index.headings.get_mut(from) {
from_record.referenced_headings.push(to.clone());
}
}
fn heading_ref_string(id: &HeadingIdentifier) -> String {
format!("{}#{}", id.artifact.workspace_path, id.slug)
}
fn build_manifest_for_save(
workspace: &WorkspacePaths,
canonical_spec_impl: &[(ArtifactKind, PathBuf)],
index_file: &str,
) -> Result<IndexManifest, SpecmanError> {
let fingerprint = root_fingerprint(workspace)?;
build_manifest(fingerprint, workspace, canonical_spec_impl, index_file)
}