agpm_cli/markdown/
mod.rs

1//! Markdown file operations and metadata extraction for Claude Code resources.
2//!
3//! This module provides comprehensive support for reading, writing, and manipulating
4//! Markdown files that contain Claude Code agents and snippets. It handles both
5//! plain Markdown files and files with structured metadata in frontmatter.
6//!
7//! # Overview
8//!
9//! The markdown module is a core component of AGPM that:
10//! - Parses Markdown files with optional YAML or TOML frontmatter
11//! - Extracts structured metadata for dependency resolution
12//! - Preserves document structure during read/write operations
13//! - Provides utilities for file discovery and validation
14//! - Supports atomic file operations for safe installation
15//! - Extracts and validates file references within markdown content
16//!
17//! # Supported File Formats
18//!
19//! ## Plain Markdown Files
20//!
21//! Standard Markdown files without frontmatter are fully supported:
22//!
23//! ```markdown
24//! # Python Code Reviewer
25//!
26//! This agent specializes in reviewing Python code for:
27//! - PEP 8 compliance
28//! - Security vulnerabilities
29//! - Performance optimizations
30//!
31//! ## Usage
32//!
33//! When reviewing code, I will...
34//! ```
35//!
36//! ## YAML Frontmatter Format
37//!
38//! Files can include YAML frontmatter for structured metadata:
39//!
40//! ```markdown
41//! ---
42//! title: "Python Code Reviewer"
43//! description: "Specialized agent for Python code quality review"
44//! version: "2.1.0"
45//! author: "Claude Code Team"
46//! type: "agent"
47//! tags:
48//!   - "python"
49//!   - "code-review"
50//!   - "quality"
51//! dependencies:
52//!   agents:
53//!     - path: agents/syntax-checker.md
54//!   snippets:
55//!     - path: snippets/security-scanner.md
56//! ---
57//!
58//! # Python Code Reviewer
59//!
60//! This agent specializes in reviewing Python code...
61//! ```
62//!
63//! ## TOML Frontmatter Format
64//!
65//! TOML frontmatter is also supported using `+++` delimiters:
66//!
67//! ```text
68//! +++
69//! title = "JavaScript Snippet Collection"
70//! description = "Useful JavaScript utilities and helpers"
71//! version = "1.0.0"
72//! author = "Community Contributors"
73//! type = "snippet"
74//! tags = ["javascript", "utilities", "helpers"]
75//! +++
76//!
77//! # JavaScript Snippet Collection
78//!
79//! ## Array Utilities
80//!
81//! ```javascript
82//! function unique(arr) {
83//!     return [...new Set(arr)];
84//! }
85//! ```
86//!
87//! # Metadata Schema
88//!
89//! The frontmatter metadata follows this schema:
90//!
91//! | Field | Type | Description | Required |
92//! |-------|------|-------------|----------|
93//! | title | string | Human-readable resource title | No |
94//! | description | string | Brief description of the resource | No |
95//! | version | string | Resource version (semver recommended) | No |
96//! | author | string | Author name or organization | No |
97//! | type | string | Resource type ("agent" or "snippet") | No |
98//! | tags | array | Tags for categorization | No |
99//! | dependencies | object | Structured dependencies by resource type | No |
100//!
101//! Additional custom fields are preserved in the extra map.
102//!
103//! # Content Extraction
104//!
105//! When metadata is not explicitly provided in frontmatter, the module
106//! can extract information from the Markdown content:
107//!
108//! - **Title**: Extracted from the first level-1 heading in the content
109//! - **Description**: Extracted from the first paragraph after headings
110//!
111//! This allows resources to work without frontmatter while still providing
112//! useful metadata for dependency resolution and display.
113//!
114//! # File Operations
115//!
116//! All file operations are designed to be safe and atomic:
117//! - Parent directories are created automatically during writes
118//! - Content is validated during parsing to catch errors early  
119//! - File extensions are validated (.md, .markdown)
120//! - Recursive directory traversal for bulk operations
121//!
122//! # Usage Examples
123//!
124//! ## Basic Reading and Writing
125//!
126//! ```rust,no_run
127//! use agpm_cli::markdown::MarkdownDocument;
128//! use std::path::Path;
129//!
130//! # fn example() -> anyhow::Result<()> {
131//! // Read a markdown file
132//! let doc = MarkdownDocument::read(Path::new("agents/reviewer.md"))?;
133//!
134//! // Access metadata
135//! if let Some(metadata) = &doc.metadata {
136//!     println!("Title: {:?}", metadata.title);
137//!     println!("Version: {:?}", metadata.version);
138//!     println!("Tags: {:?}", metadata.tags);
139//! }
140//!
141//! // Extract title from content if not in metadata
142//! if let Some(title) = doc.get_title() {
143//!     println!("Extracted title: {}", title);
144//! }
145//!
146//! // Write to a new location
147//! doc.write(Path::new("installed/reviewer.md"))?;
148//! # Ok(())
149//! # }
150//! ```
151//!
152//! ## Creating Documents Programmatically
153//!
154//! ```rust,no_run
155//! use agpm_cli::markdown::{MarkdownDocument, MarkdownMetadata};
156//!
157//! # fn example() -> anyhow::Result<()> {
158//! // Create metadata
159//! let mut metadata = MarkdownMetadata::default();
160//! metadata.title = Some("Custom Agent".to_string());
161//! metadata.version = Some("1.0.0".to_string());
162//! metadata.tags = vec!["custom".to_string(), "utility".to_string()];
163//!
164//! // Create document with metadata
165//! let content = "# Custom Agent\n\nThis is a custom agent...";
166//! let doc = MarkdownDocument::with_metadata(metadata, content.to_string());
167//!
168//! // The raw field contains formatted frontmatter + content
169//! println!("{}", doc.raw);
170//! # Ok(())
171//! # }
172//! ```
173//!
174//! ## Batch File Processing
175//!
176//! ```rust,no_run
177//! use agpm_cli::markdown::{list_markdown_files, MarkdownDocument};
178//! use std::path::Path;
179//!
180//! # fn example() -> anyhow::Result<()> {
181//! // Find all markdown files in a directory
182//! let files = list_markdown_files(Path::new("resources/"))?;
183//!
184//! for file in files {
185//!     let doc = MarkdownDocument::read(&file)?;
186//!     
187//!     if let Some(title) = doc.get_title() {
188//!         println!("{}: {}", file.display(), title);
189//!     }
190//! }
191//! # Ok(())
192//! # }
193//! ```
194//!
195//! # Integration with AGPM
196//!
197//! This module integrates with other AGPM components:
198//!
199//! - `crate::manifest`: Uses metadata for dependency resolution
200//! - `crate::lockfile`: Stores checksums and installation paths  
201//! - `crate::source`: Handles remote resource fetching
202//! - `crate::core`: Provides core types and error handling
203//!
204//! See the respective module documentation for integration details.
205
206pub mod frontmatter;
207pub mod reference_extractor;
208
209use anyhow::{Context, Result};
210use serde::{Deserialize, Serialize};
211use std::collections::BTreeMap;
212use std::fs;
213use std::path::Path;
214
215use crate::core::OperationContext;
216use crate::manifest::{DependencySpec, dependency_spec::AgpmMetadata};
217use crate::markdown::frontmatter::{FrontmatterParser, ParsedFrontmatter};
218
219/// Type alias for [`MarkdownDocument`] for backward compatibility.
220///
221/// This alias exists to provide a consistent naming convention and maintain
222/// backward compatibility with existing code that might use `MarkdownFile`.
223/// New code should prefer using [`MarkdownDocument`] directly.
224///
225/// # Examples
226///
227/// ```rust,no_run
228/// # use agpm_cli::markdown::{MarkdownFile, MarkdownDocument};
229/// // These are equivalent
230/// let doc1 = MarkdownDocument::new("content".to_string());
231/// let doc2 = MarkdownFile::new("content".to_string());
232///
233/// assert_eq!(doc1.content, doc2.content);
234/// ```
235pub type MarkdownFile = MarkdownDocument;
236
237/// Structured metadata extracted from Markdown frontmatter.
238///
239/// This struct represents all the metadata that can be parsed from YAML or TOML
240/// frontmatter in Markdown files. It follows a flexible schema that accommodates
241/// both standard AGPM fields and custom extensions.
242///
243/// # Standard Fields
244///
245/// The following fields have special meaning in AGPM:
246/// - `title`: Human-readable name for the resource
247/// - `description`: Brief explanation of what the resource does
248/// - `version`: Version identifier (semantic versioning recommended)
249/// - `author`: Creator or maintainer information
250/// - `resource_type`: Type classification ("agent" or "snippet")
251/// - `tags`: Categorization labels for filtering and discovery
252/// - `dependencies`: Structured dependencies for transitive resolution
253///
254/// # Custom Fields
255///
256/// Additional fields are preserved in the `extra` map, allowing resource
257/// authors to include custom metadata without breaking compatibility.
258///
259/// # Serialization
260///
261/// The struct uses Serde for serialization with skip-if-empty optimizations
262/// to keep generated frontmatter clean. Empty collections and None values
263/// are omitted from the output.
264///
265/// # Example
266///
267/// ```rust,no_run
268/// # use agpm_cli::markdown::MarkdownMetadata;
269/// # use std::collections::{BTreeMap, HashMap};
270/// let mut metadata = MarkdownMetadata::default();
271/// metadata.title = Some("Python Linter".to_string());
272/// metadata.version = Some("2.0.1".to_string());
273/// metadata.tags = vec!["python".to_string(), "linting".to_string()];
274/// // Dependencies can be set as a JSON value for the structured format
275/// // This is typically parsed from frontmatter rather than set programmatically
276///
277/// // Custom fields via extra map
278/// let mut extra = BTreeMap::new();
279/// extra.insert("license".to_string(), "MIT".into());
280/// extra.insert("min_python".to_string(), "3.8".into());
281/// metadata.extra = extra;
282/// ```
283#[derive(Debug, Clone, Default, Serialize, Deserialize)]
284pub struct MarkdownMetadata {
285    /// Human-readable title of the resource.
286    ///
287    /// This is displayed in listings and used for resource identification.
288    /// If not provided, the title may be extracted from the first heading
289    /// in the Markdown content.
290    #[serde(skip_serializing_if = "Option::is_none")]
291    pub title: Option<String>,
292
293    /// Brief description explaining what the resource does.
294    ///
295    /// Used for documentation and resource discovery. If not provided,
296    /// the description may be extracted from the first paragraph in
297    /// the Markdown content.
298    #[serde(skip_serializing_if = "Option::is_none")]
299    pub description: Option<String>,
300
301    /// Version identifier for the resource.
302    ///
303    /// Semantic versioning (e.g., "1.2.3") is recommended for compatibility
304    /// with dependency resolution, but any string format is accepted.
305    #[serde(skip_serializing_if = "Option::is_none")]
306    pub version: Option<String>,
307
308    /// Author or maintainer information.
309    ///
310    /// Can be a name, organization, or contact information. Free-form text.
311    #[serde(skip_serializing_if = "Option::is_none")]
312    pub author: Option<String>,
313
314    /// Classification tags for categorization and filtering.
315    ///
316    /// Tags help with resource discovery and organization. Common patterns:
317    /// - Language-specific: "python", "javascript", "rust"
318    /// - Functionality: "linting", "testing", "documentation"
319    /// - Domain: "web-dev", "data-science", "devops"
320    #[serde(default, skip_serializing_if = "Vec::is_empty")]
321    pub tags: Vec<String>,
322
323    /// Resource type classification.
324    ///
325    /// Currently supported types:
326    /// - "agent": Interactive Claude Code agents
327    /// - "snippet": Code snippets and templates
328    ///
329    /// This field uses `rename = "type"` to match the frontmatter format
330    /// while avoiding Rust's `type` keyword.
331    #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
332    pub resource_type: Option<String>,
333
334    /// Dependencies for this resource.
335    ///
336    /// This field uses the structured transitive dependency format where
337    /// dependencies are organized by resource type (agents, snippets, etc.).
338    /// Each resource type maps to a list of dependency specifications.
339    ///
340    /// Example:
341    /// ```yaml
342    /// dependencies:
343    ///   agents:
344    ///     - path: agents/helper.md
345    ///       version: v1.0.0
346    ///   snippets:
347    ///     - path: snippets/utils.md
348    /// ```
349    #[serde(default, skip_serializing_if = "Option::is_none")]
350    pub dependencies: Option<BTreeMap<String, Vec<DependencySpec>>>,
351
352    /// Additional custom metadata fields.
353    ///
354    /// Any frontmatter fields not recognized by the standard schema are
355    /// preserved here. This allows resource authors to include custom
356    /// metadata without breaking compatibility with AGPM.
357    ///
358    /// Values are stored as `serde_json::Value` to handle mixed types
359    /// (strings, numbers, arrays, objects).
360    /// Uses BTreeMap for deterministic serialization order.
361    #[serde(flatten)]
362    pub extra: BTreeMap<String, serde_json::Value>,
363}
364
365impl MarkdownMetadata {
366    /// Get AGPM-specific metadata from the extra fields.
367    ///
368    /// Extracts the `agpm` section from the frontmatter if present,
369    /// which may contain templating flags and nested dependencies.
370    pub fn get_agpm_metadata(&self) -> Option<AgpmMetadata> {
371        self.extra.get("agpm").and_then(|value| serde_json::from_value(value.clone()).ok())
372    }
373}
374
375/// A parsed Markdown document representing a Claude Code resource.
376///
377/// This is the core structure for handling Markdown files in AGPM. It provides
378/// a clean separation between structured metadata (from frontmatter) and the
379/// actual content, while preserving the original document format for roundtrip
380/// compatibility.
381///
382/// # Structure
383///
384/// A `MarkdownDocument` consists of three parts:
385/// 1. **Metadata**: Structured data from frontmatter (YAML or TOML)
386/// 2. **Content**: The main Markdown content without frontmatter
387/// 3. **Raw**: The complete original document for faithful reproduction
388///
389/// # Frontmatter Support
390///
391/// The document can parse both YAML (`---` delimiters) and TOML (`+++` delimiters)
392/// frontmatter formats. If no frontmatter is present, the entire file is treated
393/// as content.
394///
395/// # Content Extraction
396///
397/// When explicit metadata is not available, the document can extract information
398/// from the content itself using [`get_title`] and [`get_description`] methods.
399///
400/// # Thread Safety
401///
402/// This struct is `Clone` and can be safely passed between threads for
403/// concurrent processing of multiple documents.
404///
405/// # Examples
406///
407/// ## Reading from File
408///
409/// ```rust,no_run
410/// # use agpm_cli::markdown::MarkdownDocument;
411/// # use std::path::Path;
412/// # fn example() -> anyhow::Result<()> {
413/// let doc = MarkdownDocument::read(Path::new("agent.md"))?;
414///
415/// if let Some(metadata) = &doc.metadata {
416///     println!("Found metadata: {:?}", metadata.title);
417/// }
418///
419/// println!("Content length: {} chars", doc.content.len());
420/// # Ok(())
421/// # }
422/// ```
423///
424/// ## Creating Programmatically
425///
426/// ```rust,no_run
427/// # use agpm_cli::markdown::{MarkdownDocument, MarkdownMetadata};
428/// let metadata = MarkdownMetadata {
429///     title: Some("Test Agent".to_string()),
430///     version: Some("1.0.0".to_string()),
431///     ..Default::default()
432/// };
433///
434/// let content = "# Test Agent\n\nThis agent helps with testing.";
435/// let doc = MarkdownDocument::with_metadata(metadata, content.to_string());
436///
437/// // Raw contains formatted frontmatter + content
438/// assert!(doc.raw.contains("title: Test Agent"));
439/// assert!(doc.raw.contains("This agent helps with testing"));
440/// ```
441///
442/// ## Modifying Content
443///
444/// ```rust,no_run
445/// # use agpm_cli::markdown::MarkdownDocument;
446/// let mut doc = MarkdownDocument::new("# Original".to_string());
447///
448/// // Update content - raw is automatically regenerated
449/// doc.set_content("# Updated Content\n\nNew description.".to_string());
450///
451/// assert_eq!(doc.content, "# Updated Content\n\nNew description.");
452/// assert_eq!(doc.raw, doc.content); // No frontmatter, so raw == content
453/// ```
454///
455/// [`get_title`]: MarkdownDocument::get_title
456/// [`get_description`]: MarkdownDocument::get_description
457#[derive(Debug, Clone)]
458pub struct MarkdownDocument {
459    /// Parsed metadata extracted from frontmatter.
460    ///
461    /// This will be `Some` if the document contained valid YAML or TOML
462    /// frontmatter, and `None` for plain Markdown files. The metadata
463    /// is used by AGPM for dependency resolution and resource management.
464    pub metadata: Option<MarkdownMetadata>,
465
466    /// The main Markdown content without frontmatter delimiters.
467    ///
468    /// This contains only the actual content portion of the document,
469    /// with frontmatter stripped away. This is what gets processed
470    /// for content-based metadata extraction.
471    pub content: String,
472
473    /// The complete original document including frontmatter.
474    ///
475    /// This field preserves the exact original format for faithful
476    /// reproduction when writing back to disk. When metadata or content
477    /// is modified, this field is automatically regenerated to maintain
478    /// consistency.
479    pub raw: String,
480}
481
482impl MarkdownDocument {
483    /// Create a new markdown document without frontmatter.
484    ///
485    /// This creates a plain Markdown document with no metadata. The content
486    /// becomes both the `content` and `raw` fields since there's no frontmatter
487    /// to format.
488    ///
489    /// # Arguments
490    ///
491    /// * `content` - The Markdown content as a string
492    ///
493    /// # Examples
494    ///
495    /// ```rust,no_run
496    /// # use agpm_cli::markdown::MarkdownDocument;
497    /// let doc = MarkdownDocument::new("# Hello\n\nWorld!".to_string());
498    ///
499    /// assert!(doc.metadata.is_none());
500    /// assert_eq!(doc.content, "# Hello\n\nWorld!");
501    /// assert_eq!(doc.raw, doc.content);
502    /// ```
503    #[must_use]
504    pub fn new(content: String) -> Self {
505        Self {
506            metadata: None,
507            content: content.clone(),
508            raw: content,
509        }
510    }
511
512    /// Create a markdown document with metadata and content.
513    ///
514    /// This constructor creates a complete document with structured metadata
515    /// in YAML frontmatter format. The `raw` field will contain the formatted
516    /// frontmatter followed by the content.
517    ///
518    /// # Arguments
519    ///
520    /// * `metadata` - The structured metadata for the document
521    /// * `content` - The Markdown content (without frontmatter)
522    ///
523    /// # Examples
524    ///
525    /// ```rust,no_run
526    /// # use agpm_cli::markdown::{MarkdownDocument, MarkdownMetadata};
527    /// let metadata = MarkdownMetadata {
528    ///     title: Some("Example".to_string()),
529    ///     version: Some("1.0.0".to_string()),
530    ///     ..Default::default()
531    /// };
532    ///
533    /// let doc = MarkdownDocument::with_metadata(
534    ///     metadata,
535    ///     "# Example\n\nThis is an example.".to_string()
536    /// );
537    ///
538    /// assert!(doc.metadata.is_some());
539    /// assert!(doc.raw.starts_with("---\n"));
540    /// assert!(doc.raw.contains("title: Example"));
541    /// ```
542    #[must_use]
543    pub fn with_metadata(metadata: MarkdownMetadata, content: String) -> Self {
544        let raw = Self::format_with_frontmatter(&metadata, &content);
545        Self {
546            metadata: Some(metadata),
547            content,
548            raw,
549        }
550    }
551
552    /// Read and parse a Markdown file from the filesystem.
553    ///
554    /// This method reads the entire file into memory and parses it for
555    /// frontmatter and content. It supports both YAML and TOML frontmatter
556    /// formats and provides detailed error context on failure.
557    ///
558    /// # Arguments
559    ///
560    /// * `path` - Path to the Markdown file to read
561    ///
562    /// # Returns
563    ///
564    /// Returns a `Result` containing the parsed document or an error with
565    /// context about what went wrong (file not found, parse error, etc.).
566    ///
567    /// # Errors
568    ///
569    /// This function will return an error if:
570    /// - The file cannot be read (doesn't exist, permissions, etc.)
571    /// - The file contains invalid UTF-8
572    /// - The frontmatter is malformed YAML or TOML
573    ///
574    /// # Examples
575    ///
576    /// ```rust,no_run
577    /// # use agpm_cli::markdown::MarkdownDocument;
578    /// # use std::path::Path;
579    /// # fn example() -> anyhow::Result<()> {
580    /// let doc = MarkdownDocument::read(Path::new("resources/agent.md"))?;
581    ///
582    /// println!("Title: {:?}", doc.get_title());
583    /// println!("Content length: {}", doc.content.len());
584    /// # Ok(())
585    /// # }
586    /// ```
587    pub fn read(path: &Path) -> Result<Self> {
588        let raw = fs::read_to_string(path)
589            .with_context(|| format!("Failed to read markdown file: {}", path.display()))?;
590
591        Self::parse(&raw)
592    }
593
594    /// Write the document to a file on disk.
595    ///
596    /// This method performs an atomic write operation, creating any necessary
597    /// parent directories automatically. The complete `raw` content (including
598    /// frontmatter if present) is written to the specified path.
599    ///
600    /// # Arguments
601    ///
602    /// * `path` - Target path where the file should be written
603    ///
604    /// # Returns
605    ///
606    /// Returns `Ok(())` on success, or an error with context on failure.
607    ///
608    /// # Errors
609    ///
610    /// This function will return an error if:
611    /// - Parent directories cannot be created (permissions, disk space, etc.)
612    /// - The file cannot be written (permissions, disk space, etc.)
613    /// - The path is invalid or inaccessible
614    ///
615    /// # Safety
616    ///
617    /// This operation creates parent directories as needed, which could
618    /// potentially create unexpected directory structures if the path
619    /// is not validated by the caller.
620    ///
621    /// # Examples
622    ///
623    /// ```rust,no_run
624    /// # use agpm_cli::markdown::MarkdownDocument;
625    /// # use std::path::Path;
626    /// # fn example() -> anyhow::Result<()> {
627    /// let doc = MarkdownDocument::new("# Test\n\nContent".to_string());
628    ///
629    /// // Writes to file, creating directories as needed
630    /// doc.write(Path::new("output/resources/test.md"))?;
631    /// # Ok(())
632    /// # }
633    /// ```
634    pub fn write(&self, path: &Path) -> Result<()> {
635        // Ensure parent directory exists
636        if let Some(parent) = path.parent() {
637            fs::create_dir_all(parent)
638                .with_context(|| format!("Failed to create directory: {}", parent.display()))?;
639        }
640
641        fs::write(path, &self.raw)
642            .with_context(|| format!("Failed to write markdown file: {}", path.display()))?;
643
644        Ok(())
645    }
646
647    /// Parse a Markdown string that may contain frontmatter with context for warnings.
648    ///
649    /// Parse a Markdown string with operation context for warning deduplication.
650    ///
651    /// This is the preferred method for new code as it uses operation-scoped
652    /// context for warning deduplication.
653    ///
654    /// # Arguments
655    ///
656    /// * `input` - The complete Markdown document as a string
657    /// * `file_context` - Optional file path for warning messages (unused, kept for API compatibility)
658    /// * `operation_context` - Optional operation context for deduplication (unused, kept for API compatibility)
659    ///
660    /// # Returns
661    ///
662    /// Returns a parsed `MarkdownDocument`. If frontmatter parsing fails,
663    /// NO warning is emitted (warnings are handled by MetadataExtractor for dependency resolution).
664    /// The entire document is treated as content.
665    ///
666    /// # Examples
667    ///
668    /// ```rust,no_run
669    /// use agpm_cli::core::OperationContext;
670    /// use agpm_cli::markdown::MarkdownDocument;
671    ///
672    /// let ctx = OperationContext::new();
673    /// let markdown = "---\ntitle: Example\n---\n# Content";
674    ///
675    /// let doc = MarkdownDocument::parse_with_operation_context(
676    ///     markdown,
677    ///     Some("example.md"),
678    ///     Some(&ctx)
679    /// ).unwrap();
680    ///
681    /// assert!(doc.metadata.is_some());
682    /// ```
683    pub fn parse_with_operation_context(
684        input: &str,
685        _file_context: Option<&str>,
686        _operation_context: Option<&OperationContext>,
687    ) -> Result<Self> {
688        let parser = FrontmatterParser::new();
689        let result = parser.parse::<MarkdownMetadata>(input).or_else::<anyhow::Error, _>(|_| {
690            // If parsing fails, treat entire document as content (preserving old behavior)
691            Ok(ParsedFrontmatter {
692                data: None,
693                content: input.to_string(),
694                raw_frontmatter: None,
695                templated: false,
696                rendered_frontmatter: None,
697                boundaries: None,
698            })
699        })?;
700
701        Ok(Self {
702            metadata: result.data,
703            content: result.content,
704            raw: input.to_string(),
705        })
706    }
707
708    /// Parse a Markdown string that may contain frontmatter.
709    ///
710    /// This is the core parsing method that handles both YAML and TOML
711    /// frontmatter formats. It attempts to detect and parse frontmatter,
712    /// falling back to treating the entire input as content if no valid
713    /// frontmatter is found.
714    ///
715    /// # Supported Formats
716    ///
717    /// ## YAML Frontmatter (recommended)
718    /// ```text
719    /// ---
720    /// title: "Example"
721    /// version: "1.0.0"
722    /// ---
723    /// Content here...
724    /// ```
725    ///
726    /// ## TOML Frontmatter
727    /// ```text
728    /// +++
729    /// title = "Example"
730    /// version = "1.0.0"
731    /// +++
732    /// Content here...
733    /// ```
734    ///
735    /// # Arguments
736    ///
737    /// * `input` - The complete Markdown document as a string
738    ///
739    /// # Returns
740    ///
741    /// Returns a parsed `MarkdownDocument` with metadata extracted if present.
742    ///
743    /// # Errors
744    ///
745    /// Returns an error if the frontmatter is present but malformed:
746    /// - Invalid YAML syntax in `---` delimited frontmatter
747    /// - Invalid TOML syntax in `+++` delimited frontmatter
748    /// - Frontmatter that doesn't match the expected metadata schema
749    ///
750    /// # Examples
751    ///
752    /// ```rust,no_run
753    /// # use agpm_cli::markdown::MarkdownDocument;
754    /// // Parse document with YAML frontmatter
755    /// let input = "---\ntitle: Test\n---\n# Content";
756    /// let doc = MarkdownDocument::parse(input).unwrap();
757    /// assert!(doc.metadata.is_some());
758    ///
759    /// // Parse plain Markdown
760    /// let input = "# Just Content";
761    /// let doc = MarkdownDocument::parse(input).unwrap();
762    /// assert!(doc.metadata.is_none());
763    /// ```
764    pub fn parse(input: &str) -> Result<Self> {
765        Self::parse_with_operation_context(input, None, None)
766    }
767
768    /// Parse a Markdown string with template variable support.
769    ///
770    /// This method applies Tera template rendering to the frontmatter before parsing,
771    /// allowing dependencies to use conditional blocks and template variables.
772    ///
773    /// # Arguments
774    ///
775    /// * `input` - The complete Markdown document as a string
776    /// * `variant_inputs` - Optional template variables (project, config, etc.)
777    /// * `file_path` - Optional file path for error reporting
778    ///
779    /// # Returns
780    ///
781    /// Returns a parsed `MarkdownDocument` with frontmatter templates resolved.
782    ///
783    /// # Examples
784    ///
785    /// ```rust,no_run
786    /// use agpm_cli::markdown::MarkdownDocument;
787    /// use std::path::Path;
788    ///
789    /// let variant_inputs = serde_json::json!({
790    ///     "project": {
791    ///         "framework": "react"
792    ///     }
793    /// });
794    ///
795    /// let markdown = r#"---
796    /// dependencies:
797    ///   snippets:
798    ///     {% if agpm.project.framework %}
799    ///     - name: framework
800    ///       path: {{ agpm.project.framework }}.md
801    ///     {% endif %}
802    /// ---
803    /// # Content"#;
804    ///
805    /// let doc = MarkdownDocument::parse_with_templating(
806    ///     markdown,
807    ///     Some(&variant_inputs),
808    ///     Some(Path::new("test.md"))
809    /// ).unwrap();
810    ///
811    /// assert!(doc.metadata.is_some());
812    /// ```
813    pub fn parse_with_templating(
814        input: &str,
815        variant_inputs: Option<&serde_json::Value>,
816        file_path: Option<&Path>,
817    ) -> Result<Self> {
818        let mut parser = FrontmatterParser::new();
819        let path = file_path.unwrap_or_else(|| Path::new("unknown.md"));
820
821        let result = parser
822            .parse_with_templating::<MarkdownMetadata>(input, variant_inputs, path, None)
823            .or_else::<anyhow::Error, _>(|_| {
824                // If parsing fails, treat entire document as content (preserving old behavior)
825                Ok(ParsedFrontmatter {
826                    data: None,
827                    content: input.to_string(),
828                    raw_frontmatter: None,
829                    templated: false,
830                    rendered_frontmatter: None,
831                    boundaries: None,
832                })
833            })?;
834
835        Ok(Self {
836            metadata: result.data,
837            content: result.content,
838            raw: input.to_string(),
839        })
840    }
841
842    /// Format a document with YAML frontmatter
843    fn format_with_frontmatter(metadata: &MarkdownMetadata, content: &str) -> String {
844        let yaml = serde_yaml::to_string(metadata).unwrap_or_default();
845        // Trim trailing whitespace from YAML and ensure newline before closing delimiter
846        // This prevents the closing --- from being concatenated with the YAML content
847        let yaml_trimmed = yaml.trim_end();
848        format!("---\n{}\n---\n\n{}", yaml_trimmed, content)
849    }
850
851    /// Update the document's metadata and regenerate the raw content.
852    ///
853    /// This method replaces the current metadata (if any) with new metadata
854    /// and automatically regenerates the `raw` field to include properly
855    /// formatted YAML frontmatter.
856    ///
857    /// # Arguments
858    ///
859    /// * `metadata` - The new metadata to set for this document
860    ///
861    /// # Effects
862    ///
863    /// - Sets `self.metadata` to `Some(metadata)`
864    /// - Regenerates `self.raw` with YAML frontmatter + content
865    /// - Preserves the existing `content` field unchanged
866    ///
867    /// # Examples
868    ///
869    /// ```rust,no_run
870    /// # use agpm_cli::markdown::{MarkdownDocument, MarkdownMetadata};
871    /// let mut doc = MarkdownDocument::new("# Test\n\nContent".to_string());
872    /// assert!(doc.metadata.is_none());
873    ///
874    /// let metadata = MarkdownMetadata {
875    ///     title: Some("New Title".to_string()),
876    ///     version: Some("2.0.0".to_string()),
877    ///     ..Default::default()
878    /// };
879    ///
880    /// doc.set_metadata(metadata);
881    /// assert!(doc.metadata.is_some());
882    /// assert!(doc.raw.contains("title: New Title"));
883    /// assert!(doc.raw.contains("# Test"));
884    /// ```
885    pub fn set_metadata(&mut self, metadata: MarkdownMetadata) {
886        self.raw = Self::format_with_frontmatter(&metadata, &self.content);
887        self.metadata = Some(metadata);
888    }
889
890    /// Update the document's content and regenerate the raw document.
891    ///
892    /// This method replaces the current content with new content and
893    /// automatically regenerates the `raw` field. If metadata is present,
894    /// the raw content will include formatted frontmatter; otherwise it
895    /// will be just the new content.
896    ///
897    /// # Arguments
898    ///
899    /// * `content` - The new Markdown content (without frontmatter)
900    ///
901    /// # Effects
902    ///
903    /// - Sets `self.content` to the new content
904    /// - Regenerates `self.raw` appropriately:
905    ///   - If metadata exists: frontmatter + new content
906    ///   - If no metadata: just the new content
907    /// - Preserves existing metadata unchanged
908    ///
909    /// # Examples
910    ///
911    /// ```rust,no_run
912    /// # use agpm_cli::markdown::{MarkdownDocument, MarkdownMetadata};
913    /// // Document with metadata
914    /// let metadata = MarkdownMetadata {
915    ///     title: Some("Test".to_string()),
916    ///     ..Default::default()
917    /// };
918    /// let mut doc = MarkdownDocument::with_metadata(
919    ///     metadata,
920    ///     "Original content".to_string()
921    /// );
922    ///
923    /// doc.set_content("# New Content\n\nUpdated!".to_string());
924    ///
925    /// assert_eq!(doc.content, "# New Content\n\nUpdated!");
926    /// assert!(doc.raw.contains("title: Test"));
927    /// assert!(doc.raw.contains("# New Content"));
928    /// ```
929    pub fn set_content(&mut self, content: String) {
930        if let Some(ref metadata) = self.metadata {
931            self.raw = Self::format_with_frontmatter(metadata, &content);
932        } else {
933            self.raw = content.clone();
934        }
935        self.content = content;
936    }
937
938    /// Extract the document title from metadata or content.
939    ///
940    /// This method provides a fallback mechanism for getting the document title:
941    /// 1. First, check if metadata contains an explicit title
942    /// 2. If not, scan the content for the first level-1 heading (`# Title`)
943    /// 3. Return `None` if neither source provides a title
944    ///
945    /// # Returns
946    ///
947    /// - `Some(String)` containing the title if found
948    /// - `None` if no title is available from either source
949    ///
950    /// # Title Extraction Rules
951    ///
952    /// When extracting from content:
953    /// - Only level-1 headings (starting with `# `) are considered
954    /// - The first matching heading is used
955    /// - Leading/trailing whitespace is trimmed from the result
956    /// - Empty headings (just `#`) are ignored
957    ///
958    /// # Examples
959    ///
960    /// ```rust,no_run
961    /// # use agpm_cli::markdown::{MarkdownDocument, MarkdownMetadata};
962    /// // From metadata
963    /// let metadata = MarkdownMetadata {
964    ///     title: Some("Metadata Title".to_string()),
965    ///     ..Default::default()
966    /// };
967    /// let doc = MarkdownDocument::with_metadata(
968    ///     metadata,
969    ///     "# Content Title\n\nSome text".to_string()
970    /// );
971    /// assert_eq!(doc.get_title(), Some("Metadata Title".to_string()));
972    ///
973    /// // From content heading
974    /// let doc = MarkdownDocument::new("# Extracted Title\n\nContent".to_string());
975    /// assert_eq!(doc.get_title(), Some("Extracted Title".to_string()));
976    ///
977    /// // No title available
978    /// let doc = MarkdownDocument::new("Just some content without headings".to_string());
979    /// assert_eq!(doc.get_title(), None);
980    /// ```
981    #[must_use]
982    pub fn get_title(&self) -> Option<String> {
983        // First check metadata
984        if let Some(ref metadata) = self.metadata
985            && let Some(ref title) = metadata.title
986        {
987            return Some(title.clone());
988        }
989
990        // Try to extract from first # heading
991        for line in self.content.lines() {
992            if let Some(heading) = line.strip_prefix("# ") {
993                return Some(heading.trim().to_string());
994            }
995        }
996
997        None
998    }
999
1000    /// Extract the document description from metadata or content.
1001    ///
1002    /// This method provides a fallback mechanism for getting the document description:
1003    /// 1. First, check if metadata contains an explicit description
1004    /// 2. If not, extract the first paragraph from the content (after any headings)
1005    /// 3. Return `None` if neither source provides a description
1006    ///
1007    /// # Returns
1008    ///
1009    /// - `Some(String)` containing the description if found
1010    /// - `None` if no description is available from either source
1011    ///
1012    /// # Description Extraction Rules
1013    ///
1014    /// When extracting from content:
1015    /// - All headings (lines starting with `#`) are skipped
1016    /// - Empty lines before the first paragraph are ignored
1017    /// - The first continuous block of non-empty lines becomes the description
1018    /// - Multiple lines are joined with spaces
1019    /// - Extraction stops at the first empty line after content starts
1020    ///
1021    /// # Examples
1022    ///
1023    /// ```rust,no_run
1024    /// # use agpm_cli::markdown::{MarkdownDocument, MarkdownMetadata};
1025    /// // From metadata
1026    /// let metadata = MarkdownMetadata {
1027    ///     description: Some("Metadata description".to_string()),
1028    ///     ..Default::default()
1029    /// };
1030    /// let doc = MarkdownDocument::with_metadata(
1031    ///     metadata,
1032    ///     "# Title\n\nContent description".to_string()
1033    /// );
1034    /// assert_eq!(doc.get_description(), Some("Metadata description".to_string()));
1035    ///
1036    /// // From content paragraph
1037    /// let doc = MarkdownDocument::new(
1038    ///     "# Title\n\nThis is the first\nparagraph of content.\n\nSecond paragraph.".to_string()
1039    /// );
1040    /// assert_eq!(doc.get_description(), Some("This is the first paragraph of content.".to_string()));
1041    ///
1042    /// // No description available  
1043    /// let doc = MarkdownDocument::new("# Just a title".to_string());
1044    /// assert_eq!(doc.get_description(), None);
1045    /// ```
1046    #[must_use]
1047    pub fn get_description(&self) -> Option<String> {
1048        // First check metadata
1049        if let Some(ref metadata) = self.metadata
1050            && let Some(ref desc) = metadata.description
1051        {
1052            return Some(desc.clone());
1053        }
1054
1055        // Try to extract first non-heading paragraph
1056        let mut in_paragraph = false;
1057        let mut paragraph = String::new();
1058
1059        for line in self.content.lines() {
1060            let trimmed = line.trim();
1061
1062            // Skip headings and empty lines at start
1063            if trimmed.starts_with('#') || (trimmed.is_empty() && !in_paragraph) {
1064                continue;
1065            }
1066
1067            // Start collecting paragraph
1068            if !trimmed.is_empty() {
1069                in_paragraph = true;
1070                if !paragraph.is_empty() {
1071                    paragraph.push(' ');
1072                }
1073                paragraph.push_str(trimmed);
1074            } else if in_paragraph {
1075                // End of first paragraph
1076                break;
1077            }
1078        }
1079
1080        if paragraph.is_empty() {
1081            None
1082        } else {
1083            Some(paragraph)
1084        }
1085    }
1086}
1087
1088/// Check if a path represents a Markdown file based on its extension.
1089///
1090/// This function validates file paths to determine if they should be treated
1091/// as Markdown files. It performs case-insensitive extension checking to
1092/// support different naming conventions across platforms.
1093///
1094/// # Supported Extensions
1095///
1096/// - `.md` (most common)
1097/// - `.markdown` (verbose form)
1098/// - Case variations: `.MD`, `.Markdown`, etc.
1099///
1100/// # Arguments
1101///
1102/// * `path` - The file path to check
1103///
1104/// # Returns
1105///
1106/// - `true` if the file has a recognized Markdown extension
1107/// - `false` otherwise (including files with no extension)
1108///
1109/// # Examples
1110///
1111/// ```rust,no_run
1112/// # use agpm_cli::markdown::is_markdown_file;
1113/// # use std::path::Path;
1114/// assert!(is_markdown_file(Path::new("agent.md")));
1115/// assert!(is_markdown_file(Path::new("README.MD")));
1116/// assert!(is_markdown_file(Path::new("guide.markdown")));
1117/// assert!(!is_markdown_file(Path::new("config.toml")));
1118/// assert!(!is_markdown_file(Path::new("script.sh")));
1119/// assert!(!is_markdown_file(Path::new("no-extension")));
1120/// ```
1121#[must_use]
1122pub fn is_markdown_file(path: &Path) -> bool {
1123    path.extension()
1124        .and_then(|ext| ext.to_str())
1125        .is_some_and(|ext| ext.eq_ignore_ascii_case("md") || ext.eq_ignore_ascii_case("markdown"))
1126}
1127
1128/// Recursively find all Markdown files in a directory.
1129///
1130/// This function performs a recursive traversal of the given directory,
1131/// collecting all files that have Markdown extensions. It follows symbolic
1132/// links and handles filesystem errors gracefully.
1133///
1134/// # Directory Traversal
1135///
1136/// - Recursively traverses all subdirectories
1137/// - Follows symbolic links (may cause infinite loops with circular links)
1138/// - Silently skips entries that cannot be accessed
1139/// - Only includes regular files (not directories or special files)
1140///
1141/// # Arguments
1142///
1143/// * `dir` - The directory path to search
1144///
1145/// # Returns
1146///
1147/// - `Ok(Vec<PathBuf>)` - List of absolute paths to Markdown files
1148/// - `Err(...)` - Only on severe filesystem errors (rare)
1149///
1150/// # Behavior
1151///
1152/// - Returns empty vector if directory doesn't exist (not an error)
1153/// - Files are returned in filesystem order (not sorted)
1154/// - Paths are absolute and canonicalized
1155/// - Uses [`is_markdown_file`] for extension validation
1156///
1157/// # Examples
1158///
1159/// ```rust,no_run
1160/// # use agpm_cli::markdown::list_markdown_files;
1161/// # use std::path::Path;
1162/// # fn example() -> anyhow::Result<()> {
1163/// let files = list_markdown_files(Path::new("resources/"))?;
1164///
1165/// for file in files {
1166///     println!("Found: {}", file.display());
1167/// }
1168/// # Ok(())
1169/// # }
1170/// ```
1171///
1172/// # Performance
1173///
1174/// This function loads directory metadata but not file contents, making it
1175/// suitable for scanning large directory trees. For processing the files,
1176/// consider using [`MarkdownDocument::read`] on each result.
1177///
1178/// [`is_markdown_file`]: is_markdown_file
1179/// [`MarkdownDocument::read`]: MarkdownDocument::read
1180pub fn list_markdown_files(dir: &Path) -> Result<Vec<std::path::PathBuf>> {
1181    let mut files = Vec::new();
1182
1183    if !dir.exists() {
1184        return Ok(files);
1185    }
1186
1187    for entry in walkdir::WalkDir::new(dir)
1188        .follow_links(true)
1189        .into_iter()
1190        .filter_map(std::result::Result::ok)
1191    {
1192        let path = entry.path();
1193        if path.is_file() && is_markdown_file(path) {
1194            files.push(path.to_path_buf());
1195        }
1196    }
1197
1198    Ok(files)
1199}
1200
1201#[cfg(test)]
1202mod tests {
1203    use super::*;
1204    use tempfile::tempdir;
1205
1206    #[test]
1207    fn test_markdown_document_new() {
1208        let doc = MarkdownDocument::new("# Hello World".to_string());
1209        assert!(doc.metadata.is_none());
1210        assert_eq!(doc.content, "# Hello World");
1211        assert_eq!(doc.raw, "# Hello World");
1212    }
1213
1214    #[test]
1215    fn test_markdown_with_yaml_frontmatter() {
1216        let input = r"---
1217title: Test Document
1218description: A test document
1219tags:
1220  - test
1221  - example
1222---
1223
1224# Hello World
1225
1226This is the content.";
1227
1228        let doc = MarkdownDocument::parse(input).unwrap();
1229        assert!(doc.metadata.is_some());
1230
1231        let metadata = doc.metadata.unwrap();
1232        assert_eq!(metadata.title, Some("Test Document".to_string()));
1233        assert_eq!(metadata.description, Some("A test document".to_string()));
1234        assert_eq!(metadata.tags, vec!["test", "example"]);
1235
1236        assert!(doc.content.starts_with("# Hello World"));
1237    }
1238
1239    #[test]
1240    fn test_markdown_without_frontmatter() {
1241        let input = "# Hello World\n\nThis is the content.";
1242
1243        let doc = MarkdownDocument::parse(input).unwrap();
1244        assert!(doc.metadata.is_none());
1245        assert_eq!(doc.content, input);
1246    }
1247
1248    #[test]
1249    fn test_get_title() {
1250        // From metadata
1251        let metadata = MarkdownMetadata {
1252            title: Some("Metadata Title".to_string()),
1253            ..Default::default()
1254        };
1255        let doc = MarkdownDocument::with_metadata(metadata, "Content".to_string());
1256        assert_eq!(doc.get_title(), Some("Metadata Title".to_string()));
1257
1258        // From heading
1259        let doc = MarkdownDocument::new("# Heading Title\n\nContent".to_string());
1260        assert_eq!(doc.get_title(), Some("Heading Title".to_string()));
1261
1262        // No title
1263        let doc = MarkdownDocument::new("Just content".to_string());
1264        assert_eq!(doc.get_title(), None);
1265    }
1266
1267    #[test]
1268    fn test_get_description() {
1269        // From metadata
1270        let metadata = MarkdownMetadata {
1271            description: Some("Metadata description".to_string()),
1272            ..Default::default()
1273        };
1274        let doc = MarkdownDocument::with_metadata(metadata, "Content".to_string());
1275        assert_eq!(doc.get_description(), Some("Metadata description".to_string()));
1276
1277        // From first paragraph
1278        let doc = MarkdownDocument::new(
1279            "# Title\n\nThis is the first paragraph.\n\nSecond paragraph.".to_string(),
1280        );
1281        assert_eq!(doc.get_description(), Some("This is the first paragraph.".to_string()));
1282    }
1283
1284    #[test]
1285    fn test_read_write_markdown() {
1286        let temp = tempdir().unwrap();
1287        let file_path = temp.path().join("test.md");
1288
1289        // Create and write document
1290        let metadata = MarkdownMetadata {
1291            title: Some("Test".to_string()),
1292            ..Default::default()
1293        };
1294        let doc = MarkdownDocument::with_metadata(metadata, "# Test\n\nContent".to_string());
1295        doc.write(&file_path).unwrap();
1296
1297        // Read back
1298        let loaded = MarkdownDocument::read(&file_path).unwrap();
1299        assert!(loaded.metadata.is_some());
1300        assert_eq!(loaded.metadata.unwrap().title, Some("Test".to_string()));
1301        assert!(loaded.content.contains("# Test"));
1302    }
1303
1304    #[test]
1305    fn test_is_markdown_file() {
1306        assert!(is_markdown_file(Path::new("test.md")));
1307        assert!(is_markdown_file(Path::new("test.MD")));
1308        assert!(is_markdown_file(Path::new("test.markdown")));
1309        assert!(is_markdown_file(Path::new("test.MARKDOWN")));
1310        assert!(!is_markdown_file(Path::new("test.txt")));
1311        assert!(!is_markdown_file(Path::new("test")));
1312    }
1313
1314    #[test]
1315    fn test_list_markdown_files() {
1316        let temp = tempdir().unwrap();
1317
1318        // Create some files
1319        std::fs::write(temp.path().join("file1.md"), "content").unwrap();
1320        std::fs::write(temp.path().join("file2.markdown"), "content").unwrap();
1321        std::fs::write(temp.path().join("file3.txt"), "content").unwrap();
1322
1323        let subdir = temp.path().join("subdir");
1324        std::fs::create_dir(&subdir).unwrap();
1325        std::fs::write(subdir.join("file4.md"), "content").unwrap();
1326
1327        let files = list_markdown_files(temp.path()).unwrap();
1328        assert_eq!(files.len(), 3);
1329
1330        let names: Vec<String> =
1331            files.iter().map(|p| p.file_name().unwrap().to_string_lossy().to_string()).collect();
1332
1333        assert!(names.contains(&"file1.md".to_string()));
1334        assert!(names.contains(&"file2.markdown".to_string()));
1335        assert!(names.contains(&"file4.md".to_string()));
1336        assert!(!names.contains(&"file3.txt".to_string()));
1337    }
1338
1339    #[test]
1340    fn test_set_metadata_and_content() {
1341        let mut doc = MarkdownDocument::new("Initial content".to_string());
1342
1343        // Set metadata
1344        let metadata = MarkdownMetadata {
1345            title: Some("New Title".to_string()),
1346            ..Default::default()
1347        };
1348        doc.set_metadata(metadata);
1349
1350        assert!(doc.metadata.is_some());
1351        assert!(doc.raw.contains("title: New Title"));
1352        assert!(doc.raw.contains("Initial content"));
1353
1354        // Set content
1355        doc.set_content("Updated content".to_string());
1356        assert_eq!(doc.content, "Updated content");
1357        assert!(doc.raw.contains("Updated content"));
1358        assert!(doc.raw.contains("title: New Title"));
1359    }
1360
1361    #[test]
1362    fn test_invalid_frontmatter_with_escaped_newlines() {
1363        // Content with invalid YAML frontmatter (literal \n that isn't properly quoted)
1364        let input = r#"---
1365name: haiku-syntax-tool
1366description: Use this agent when you need to fix linting errors, formatting issues, type checking problems, or ensure code adheres to project-specific standards. This agent specializes in enforcing language-specific conventions, project style guides, and maintaining code quality through automated fixes. Examples:\n\n<example>\nContext: The user has just written a new Python function and wants to ensure it meets project standards.\nuser: "I've added a new sync handler function"\nassistant: "Let me review this with the code-standards-enforcer agent to ensure it meets our project standards"\n<commentary>\nSince new code was written, use the Task tool to launch the code-standards-enforcer agent to check for linting, formatting, and type issues according to CLAUDE.md standards.\n</commentary>\n</example>\n\n<example>\nContext: The user encounters linting errors during CI/CD.\nuser: "The CI pipeline is failing due to formatting issues"\nassistant: "I'll use the code-standards-enforcer agent to fix these formatting and linting issues"\n<commentary>\nWhen there are explicit linting or formatting problems, use the code-standards-enforcer agent to automatically fix them according to project standards.\n</commentary>\n</example>\n\n<example>\nContext: The user wants to ensure type hints are correct.\nuser: "Can you check if my type annotations are correct in the API module?"\nassistant: "I'll launch the code-standards-enforcer agent to verify and fix any type annotation issues"\n<commentary>\nFor type checking and annotation verification, use the code-standards-enforcer agent to ensure compliance with project typing standards.\n</commentary>\n</example>
1367model: haiku
1368---
1369
1370You are a meticulous code standards enforcement specialist"#;
1371
1372        // This should succeed but treat the entire document as content (no metadata)
1373        let result = MarkdownDocument::parse(input);
1374        match result {
1375            Ok(doc) => {
1376                // Invalid frontmatter means no metadata
1377                assert!(doc.metadata.is_none());
1378                // The entire document should be treated as content
1379                assert!(doc.content.contains("---"));
1380                assert!(doc.content.contains("name: haiku-syntax-tool"));
1381                assert!(doc.content.contains("description: Use this agent"));
1382                assert!(doc.content.contains("model: haiku"));
1383                assert!(doc.content.contains("meticulous code standards enforcement specialist"));
1384            }
1385            Err(e) => {
1386                panic!("Should not fail, but got error: {}", e);
1387            }
1388        }
1389    }
1390
1391    #[test]
1392    fn test_completely_invalid_frontmatter_fallback() {
1393        // Test with completely broken YAML
1394        let input = r#"---
1395name: test
1396description: {this is not valid yaml at all
1397model: test
1398---
1399
1400Content here"#;
1401
1402        // This should now succeed but without metadata
1403        let result = MarkdownDocument::parse(input);
1404        match result {
1405            Ok(doc) => {
1406                // Should treat entire document as content when frontmatter is invalid
1407                assert!(doc.metadata.is_none());
1408                assert!(doc.content.contains("---"));
1409                assert!(doc.content.contains("name: test"));
1410                assert!(doc.content.contains("Content here"));
1411            }
1412            Err(e) => {
1413                panic!("Should not fail, but got error: {}", e);
1414            }
1415        }
1416    }
1417}