agpm-cli 0.4.14

AGent Package Manager - A Git-based package manager for coding agents
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
//! Custom Tera filters for AGPM templates.
//!
//! This module provides template filters that extend Tera's functionality for
//! AGPM-specific use cases, such as reading project files, content manipulation,
//! and other template operations.
//!
//! # Security
//!
//! All file access is restricted to the project directory with the following protections:
//! - Only relative paths are allowed (no absolute paths)
//! - Directory traversal outside project root is prevented
//! - Only text file types are permitted (.md, .txt, .json, .toml, .yaml)
//! - Missing files produce hard errors to fail fast
//!
//! # Supported File Types
//!
//! - **Markdown (.md)**: YAML/TOML frontmatter is automatically stripped
//! - **JSON (.json)**: Parsed and pretty-printed
//! - **Text (.txt)**: Raw content
//! - **TOML (.toml)**: Raw content
//! - **YAML (.yaml, .yml)**: Raw content
//!
//! # Examples
//!
//! ## Basic File Reading
//!
//! ```markdown
//! ---
//! agpm.templating: true
//! ---
//! # Code Review Agent
//!
//! ## Style Guide
//! {{ 'project/styleguide.md' | content }}
//!
//! ## Best Practices
//! {{ 'docs/best-practices.txt' | content }}
//! ```
//!
//! ## Combining with Dependency Content Embedding
//!
//! Use both `content` filter and dependency `.content` fields together:
//!
//! ```markdown
//! ---
//! agpm.templating: true
//! dependencies:
//!   snippets:
//!     - path: snippets/rust-patterns.md
//!       name: rust_patterns
//! ---
//! # Rust Code Reviewer
//!
//! ## Shared Rust Patterns (versioned, from AGPM)
//! {{ agpm.deps.snippets.rust_patterns.content }}
//!
//! ## Project-Specific Style Guide (local)
//! {{ 'project/rust-style.md' | content }}
//! ```
//!
//! **When to use each**:
//! - **`agpm.deps.<type>.<name>.content`**: Versioned content from AGPM repositories
//! - **`content` filter**: Project-local files (team docs, company standards)
//!
//! ## Recursive Templates
//!
//! Project files can themselves contain template syntax:
//!
//! **project/styleguide.md**:
//! ```markdown
//! # Coding Standards
//!
//! ## Rust-Specific Rules
//! {{ 'project/rust-style.md' | content }}
//!
//! ## Common Guidelines
//! {{ 'project/common-style.md' | content }}
//! ```
//!
//! The template system will render up to 10 levels of nested references.

use anyhow::{Result, bail};
use std::collections::HashMap;
use std::path::{Component, Path, PathBuf};

use crate::core::file_error::{FileOperation, FileResultExt, LARGE_FILE_SIZE};

/// Allowed file extensions for project file access.
///
/// Only text-based formats are permitted to prevent binary file inclusion
/// and ensure content can be safely embedded in templates.
const ALLOWED_EXTENSIONS: &[&str] = &["md", "txt", "json", "toml", "yaml", "yml"];

/// Maximum nesting depth for recursive template rendering.
///
/// This prevents infinite loops and excessive memory usage when files
/// reference each other cyclically or create deep nesting chains.
pub const MAX_RENDER_DEPTH: usize = 10;

/// Validates a file path for security and correctness.
///
/// This function ensures that:
/// 1. The path is relative (not absolute)
/// 2. The path doesn't traverse outside the project directory using `..`
/// 3. The file extension is in the allowed list
/// 4. The file exists and is readable
/// 5. The file size doesn't exceed the maximum allowed
///
/// # Arguments
///
/// * `path_str` - The path string from the template
/// * `project_dir` - The project root directory
/// * `max_size` - Maximum file size in bytes (None for no limit)
///
/// # Returns
///
/// Returns the canonicalized absolute path to the file if all checks pass.
///
/// # Errors
///
/// Returns an error if:
/// - Path is absolute
/// - Path contains `..` components that escape project directory
/// - File extension is not in the allowed list
/// - File doesn't exist
/// - File is not accessible (permissions, etc.)
/// - File size exceeds the maximum allowed
///
/// # Security
///
/// This function is critical for preventing directory traversal attacks.
/// It validates paths before any file system access occurs.
///
/// # Examples
///
/// ```rust,no_run
/// # use std::path::Path;
/// # use agpm_cli::templating::filters::validate_content_path;
/// # fn example() -> anyhow::Result<()> {
/// let project_dir = Path::new("/home/user/project");
///
/// // Valid relative path with no size limit
/// let path = validate_content_path("docs/guide.md", project_dir, None)?;
///
/// // With size limit (1 MB)
/// let path = validate_content_path("docs/guide.md", project_dir, Some(1024 * 1024))?;
///
/// // Invalid: absolute path
/// let result = validate_content_path("/etc/passwd", project_dir, None);
/// assert!(result.is_err());
///
/// // Invalid: directory traversal
/// let result = validate_content_path("../../etc/passwd", project_dir, None);
/// assert!(result.is_err());
///
/// // Invalid: wrong extension
/// let result = validate_content_path("script.sh", project_dir, None);
/// assert!(result.is_err());
/// # Ok(())
/// # }
/// ```
pub fn validate_content_path(
    path_str: &str,
    project_dir: &Path,
    max_size: Option<u64>,
) -> Result<PathBuf> {
    // Parse the path
    let path = Path::new(path_str);

    // Reject absolute paths
    if path.is_absolute() {
        bail!(
            "Absolute paths are not allowed in content filter. \
             Path '{}' must be relative to project root.",
            path_str
        );
    }

    // Check for directory traversal attempts
    // We need to resolve the path and ensure it stays within project_dir
    let mut components_count: i32 = 0;
    for component in path.components() {
        match component {
            Component::Normal(_) => components_count += 1,
            Component::ParentDir => {
                components_count -= 1;
                // If we go negative, we're trying to escape the project directory
                if components_count < 0 {
                    bail!(
                        "Path traversal outside project directory is not allowed. \
                         Path '{}' attempts to access parent directories beyond project root.",
                        path_str
                    );
                }
            }
            Component::CurDir => {
                // `.` is fine, just ignore it
            }
            _ => {
                // Prefix, RootDir shouldn't appear in relative paths
                bail!("Invalid path component in '{}'. Only relative paths are allowed.", path_str);
            }
        }
    }

    // Validate file extension
    let extension = path.extension().and_then(|ext| ext.to_str()).ok_or_else(|| {
        anyhow::anyhow!(
            "File '{}' has no extension. Allowed extensions: {}",
            path_str,
            ALLOWED_EXTENSIONS.join(", ")
        )
    })?;

    let extension_lower = extension.to_lowercase();
    if !ALLOWED_EXTENSIONS.contains(&extension_lower.as_str()) {
        bail!(
            "File extension '.{}' is not allowed. \
             Allowed extensions: {}. \
             Path: '{}'",
            extension,
            ALLOWED_EXTENSIONS.join(", "),
            path_str
        );
    }

    // Construct full path relative to project directory
    let full_path = project_dir.join(path);

    // Check if file exists
    if !full_path.exists() {
        bail!(
            "File not found: '{}'. \
             The content filter requires files to exist. \
             Full path attempted: {}",
            path_str,
            full_path.display()
        );
    }

    // Check if it's a regular file (not a directory or symlink)
    if !full_path.is_file() {
        bail!(
            "Path '{}' is not a regular file. \
             The content filter only works with files, not directories or special files.",
            path_str
        );
    }

    // Canonicalize to get absolute path and verify it's still within project_dir
    let canonical_path = full_path.canonicalize().with_file_context(
        FileOperation::Canonicalize,
        &full_path,
        "resolving absolute path for security validation in content filter",
        "content_filter",
    )?;

    let canonical_project = project_dir.canonicalize().with_file_context(
        FileOperation::Canonicalize,
        project_dir,
        "resolving project directory for security validation in content filter",
        "content_filter",
    )?;

    // Final security check: ensure canonical path is within project directory
    if !canonical_path.starts_with(&canonical_project) {
        bail!(
            "Security violation: Path '{}' resolves to '{}' which is outside project directory '{}'",
            path_str,
            canonical_path.display(),
            canonical_project.display()
        );
    }

    // Check file size if limit is specified
    if let Some(max_bytes) = max_size {
        let metadata = canonical_path.metadata().with_file_context(
            FileOperation::Metadata,
            &canonical_path,
            "checking file size in content filter",
            "content_filter",
        )?;

        let file_size = metadata.len();
        if file_size > max_bytes {
            bail!(
                "File '{}' is too large ({} bytes). Maximum allowed size: {} bytes ({:.2} MB vs {:.2} MB limit).",
                path_str,
                file_size,
                max_bytes,
                file_size as f64 / (LARGE_FILE_SIZE as f64),
                max_bytes as f64 / (LARGE_FILE_SIZE as f64)
            );
        }
    }

    Ok(canonical_path)
}

/// Reads and processes a project file based on its type.
///
/// This function handles different file types appropriately:
/// - Markdown: Strips YAML/TOML frontmatter
/// - JSON: Parses and pretty-prints
/// - Other text files: Returns raw content
///
/// # Arguments
///
/// * `file_path` - Validated absolute path to the file
///
/// # Returns
///
/// Returns the processed file content as a string.
///
/// # Errors
///
/// Returns an error if:
/// - File cannot be read (I/O error)
/// - File contains invalid UTF-8
/// - JSON file has invalid syntax
/// - Markdown frontmatter is malformed
///
/// # Examples
///
/// ```rust,no_run
/// # use std::path::Path;
/// # use agpm_cli::templating::filters::read_and_process_content;
/// # fn example() -> anyhow::Result<()> {
/// let path = Path::new("/home/user/project/docs/guide.md");
/// let content = read_and_process_content(path)?;
/// println!("{}", content);
/// # Ok(())
/// # }
/// ```
pub fn read_and_process_content(file_path: &Path) -> Result<String> {
    // Read file content with structured context
    let content = std::fs::read_to_string(file_path).with_file_context(
        FileOperation::Read,
        file_path,
        format!("reading content for template embedding in '{}'", file_path.display()),
        "content_filter",
    )?;

    // Process based on file extension
    let extension = file_path
        .extension()
        .and_then(|ext| ext.to_str())
        .map(|s| s.to_lowercase())
        .unwrap_or_default();

    let processed_content = match extension.as_str() {
        "md" => {
            // Markdown: strip frontmatter
            match crate::markdown::MarkdownDocument::parse(&content) {
                Ok(doc) => doc.content,
                Err(e) => {
                    tracing::warn!(
                        "Failed to parse markdown file '{}': {}. Using raw content.",
                        file_path.display(),
                        e
                    );
                    content
                }
            }
        }
        "json" => {
            // JSON: parse and pretty-print
            match serde_json::from_str::<serde_json::Value>(&content) {
                Ok(json) => serde_json::to_string_pretty(&json).unwrap_or(content),
                Err(e) => {
                    tracing::warn!(
                        "Failed to parse JSON file '{}': {}. Using raw content.",
                        file_path.display(),
                        e
                    );
                    content
                }
            }
        }
        _ => {
            // Text, TOML, YAML: return raw content
            content
        }
    };

    Ok(processed_content)
}

/// Creates a Tera filter function for reading and embedding file content.
///
/// This function returns a closure that can be registered as a Tera filter.
/// The closure captures the project directory and uses it to validate and
/// read files during template rendering.
///
/// # Arguments
///
/// * `project_dir` - The project root directory for path validation
///
/// # Returns
///
/// Returns a boxed closure compatible with Tera's filter registration API.
///
/// # Filter Usage
///
/// In templates, use the filter with a string value containing the relative path:
///
/// ```markdown
/// {{ 'docs/styleguide.md' | content }}
/// ```
///
/// # Errors
///
/// The returned filter will produce template rendering errors if:
/// - The input value is not a string
/// - Path validation fails (absolute path, traversal, invalid extension, etc.)
/// - File cannot be read or processed
///
/// # Examples
///
/// ```rust,no_run
/// # use std::path::Path;
/// # use agpm_cli::core::file_error::LARGE_FILE_SIZE;
/// # use agpm_cli::templating::filters::create_content_filter;
/// # fn example() -> anyhow::Result<()> {
/// let project_dir = Path::new("/home/user/project");
/// let max_size = Some((10 * LARGE_FILE_SIZE) as u64); // 10 MB limit
/// let filter = create_content_filter(project_dir.to_path_buf(), max_size);
///
/// // Filter is registered in Tera:
/// // tera.register_filter("content", filter);
/// # Ok(())
/// # }
/// ```
pub fn create_content_filter(
    project_dir: PathBuf,
    max_size: Option<u64>,
) -> impl tera::Filter + 'static {
    move |value: &tera::Value, _args: &HashMap<String, tera::Value>| -> tera::Result<tera::Value> {
        // Extract path string from filter input
        let path_str = value
            .as_str()
            .ok_or_else(|| tera::Error::msg("content filter requires a string path"))?;

        // Validate and read the file
        let file_path = validate_content_path(path_str, &project_dir, max_size)
            .map_err(|e| tera::Error::msg(format!("content filter error: {}", e)))?;

        let content = read_and_process_content(&file_path)
            .map_err(|e| tera::Error::msg(format!("content filter error: {}", e)))?;

        // Return content as string value
        Ok(tera::Value::String(content))
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::fs;
    use tempfile::TempDir;

    fn create_test_project() -> TempDir {
        let temp = TempDir::new().unwrap();
        let project_dir = temp.path();

        // Create directory structure
        fs::create_dir_all(project_dir.join("docs")).unwrap();
        fs::create_dir_all(project_dir.join("project")).unwrap();

        // Create test files
        fs::write(project_dir.join("docs/guide.md"), "# Guide\n\nContent here").unwrap();
        fs::write(project_dir.join("docs/notes.txt"), "Plain text notes").unwrap();
        fs::write(project_dir.join("project/config.json"), r#"{"key": "value"}"#).unwrap();

        // Create markdown with frontmatter
        fs::write(
            project_dir.join("docs/with-frontmatter.md"),
            "---\ntitle: Test\n---\n\n# Content",
        )
        .unwrap();

        temp
    }

    #[test]
    fn test_validate_valid_path() -> Result<(), Box<dyn std::error::Error>> {
        let temp = create_test_project();
        let project_dir = temp.path();

        let path = validate_content_path("docs/guide.md", project_dir, None)?;
        assert!(path.ends_with("docs/guide.md"));
        assert!(path.is_absolute());
        Ok(())
    }

    #[test]
    fn test_validate_rejects_absolute_path() {
        let temp = create_test_project();
        let project_dir = temp.path();

        // Use platform-specific absolute paths
        #[cfg(windows)]
        let absolute_path = "C:\\Windows\\System32\\config";
        #[cfg(not(windows))]
        let absolute_path = "/etc/passwd";

        let result = validate_content_path(absolute_path, project_dir, None);
        assert!(result.is_err());
        assert!(result.unwrap_err().to_string().contains("Absolute paths"));
    }

    #[test]
    fn test_validate_rejects_traversal() {
        let temp = create_test_project();
        let project_dir = temp.path();

        let result = validate_content_path("../../etc/passwd", project_dir, None);
        assert!(result.is_err());
        assert!(result.unwrap_err().to_string().contains("traversal"));
    }

    #[test]
    fn test_validate_rejects_invalid_extension() {
        let temp = create_test_project();
        let project_dir = temp.path();

        // Create a .sh file
        fs::write(project_dir.join("script.sh"), "#!/bin/bash").unwrap();

        let result = validate_content_path("script.sh", project_dir, None);
        assert!(result.is_err());
        assert!(result.unwrap_err().to_string().contains("not allowed"));
    }

    #[test]
    fn test_validate_rejects_missing_file() {
        let temp = create_test_project();
        let project_dir = temp.path();

        let result = validate_content_path("docs/missing.md", project_dir, None);
        assert!(result.is_err());
        assert!(result.unwrap_err().to_string().contains("not found"));
    }

    #[test]
    fn test_validate_rejects_file_too_large() -> Result<(), Box<dyn std::error::Error>> {
        let temp = create_test_project();
        let project_dir = temp.path();

        // Create a file with known size (1000 bytes)
        let large_file = project_dir.join("large.md");
        fs::write(&large_file, "a".repeat(1000)).unwrap();

        // Should succeed with larger limit
        validate_content_path("large.md", project_dir, Some(1001))?;

        // Should fail with smaller limit
        let result = validate_content_path("large.md", project_dir, Some(999));
        assert!(result.is_err());
        let err_msg = result.unwrap_err().to_string();
        assert!(err_msg.contains("too large"));
        assert!(err_msg.contains("1000 bytes"));
        assert!(err_msg.contains("999 bytes"));
        Ok(())
    }

    #[test]
    fn test_read_markdown_strips_frontmatter() {
        let temp = create_test_project();
        let project_dir = temp.path();

        let path = project_dir.join("docs/with-frontmatter.md");
        let content = read_and_process_content(&path).unwrap();

        assert!(!content.contains("---"));
        assert!(!content.contains("title: Test"));
        assert!(content.contains("# Content"));
    }

    #[test]
    fn test_read_json_pretty_prints() {
        let temp = create_test_project();
        let project_dir = temp.path();

        let path = project_dir.join("project/config.json");
        let content = read_and_process_content(&path).unwrap();

        // Should be pretty-printed (contains newlines)
        assert!(content.contains('\n'));
        assert!(content.contains("\"key\""));
        assert!(content.contains("\"value\""));
    }

    #[test]
    fn test_read_text_returns_raw() {
        let temp = create_test_project();
        let project_dir = temp.path();

        let path = project_dir.join("docs/notes.txt");
        let content = read_and_process_content(&path).unwrap();

        assert_eq!(content, "Plain text notes");
    }

    #[test]
    fn test_filter_function() {
        use tera::Tera;

        let temp = create_test_project();
        let project_dir = temp.path().to_path_buf();

        // Register the filter in a Tera instance
        let mut tera = Tera::default();
        tera.register_filter("content", create_content_filter(project_dir, None));

        // Test with valid path using Tera's template rendering
        let template = r#"{{ 'docs/guide.md' | content }}"#;
        let context = tera::Context::new();

        let result = tera.render_str(template, &context);
        assert!(result.is_ok(), "Filter should render successfully");

        let content = result.unwrap();
        assert!(content.contains("# Guide"));
        assert!(content.contains("Content here"));
    }

    #[test]
    fn test_filter_rejects_non_string() {
        use tera::Tera;

        let temp = create_test_project();
        let project_dir = temp.path().to_path_buf();

        // Register the filter in a Tera instance
        let mut tera = Tera::default();
        tera.register_filter("content", create_content_filter(project_dir, None));

        // Test with number instead of string (this will be caught at template render time)
        let template = r#"{{ 42 | content }}"#;
        let context = tera::Context::new();

        let result = tera.render_str(template, &context);
        // The important thing is that it fails - Tera may wrap our error message
        assert!(result.is_err(), "Filter should reject non-string values");
    }

    #[test]
    fn test_recursive_template_rendering() {
        // This test is in the templating module tests
        // See test_recursive_content_rendering in mod.rs
    }
}

// Integration tests for recursive rendering have been removed since multi-pass rendering was
// removed in v0.5.0. The content filter now returns literal content without template processing.
// If you need template processing, make files into AGPM dependencies instead.