Skip to content

Commit 0620a8a

Browse files
committed
feat: add support for appending and prepending file content
This commit introduces two new file manipulation actions: "Append File" and "Prepend File". Users can now define these actions in their markdown input to add content to the end or beginning of specified files. Key features and changes: - **New Headers:** Recognizes `## Append File: <path>` / `**Append File: <path>**` and `## Prepend File: <path>` / `**Prepend File: <path>**`. These headers must be followed by a code block containing the content to append or prepend. - **File Creation:** If the target file for an append or prepend action does not exist, it will be created with the specified content. - **Parser Updates:** - `ActionType` enum extended with `Append` and `Prepend`. - Header parsing logic (`header_utils.rs`, `external_header.rs`, `internal_standard_handler.rs`, `wrapped_header.rs`) updated to recognize and process these new action types. - `constants.rs` updated with new action keywords. - **Processor Implementation:** - New modules `src/processor/append.rs` and `src/processor/prepend.rs` implement the core logic. - `action_handler.rs` dispatches to these new handlers. - Error handling (`errors.rs`) includes new variants for append/prepend to a directory (`TargetIsDirectoryForAppend`, `TargetIsDirectoryForPrepend`). - **Core Types & Summary:** - `Summary` struct in `core_types.rs` now includes fields for `appended`, `prepended`, `failed_isdir_append`, and `failed_isdir_prepend`. - New status enums `AppendStatus` and `PrependStatus` introduced. - CLI output (`cli/output.rs`) and summary updating logic (`processor/summary_updater.rs`) reflect these additions. - **Documentation:** `README.md` updated to document the new actions, their syntax, and examples. - **Testing:** Comprehensive integration tests added for parsing, processing, and CLI usage of append/prepend actions, including interactions with other actions and error scenarios. This enhancement provides more granular control over file content generation, allowing users to build up files incrementally.
1 parent 093affe commit 0620a8a

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

41 files changed

+1554
-287
lines changed

README.md

Lines changed: 71 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,19 @@
66
* **Documentation:** Document file structures and provide their content within a single markdown file.
77
* **Reproducible Setups:** Define file layouts declaratively.
88

9-
The tool reads a markdown file, identifies actions (like creating, deleting, or moving files) based on specific header formats, and executes these actions relative to a specified output directory.
9+
The tool reads a markdown file, identifies actions (like creating, appending, prepending, deleting, or moving files) based on specific header formats, and executes these actions relative to a specified output directory.
1010

1111
## Features
1212

1313
* Supports creating files with content defined in code blocks.
14+
* Supports appending content to existing files (creates if not exists).
15+
* Supports prepending content to existing files (creates if not exists).
1416
* Supports deleting files.
1517
* Supports moving files.
1618
* Multiple header formats for defining actions (Markdown headers, backticks, internal comments).
1719
* "Wrapped" header format for associating headers with subsequent code blocks or for standalone delete/move actions.
18-
* Automatic creation of parent directories for created or moved files.
19-
* Safety checks to prevent writing or moving outside the target base directory.
20+
* Automatic creation of parent directories for created, appended, prepended or moved files.
21+
* Safety checks to prevent writing or moving files outside the target base directory.
2022
* Option to force overwriting existing files (for create and move actions).
2123
* Detailed summary output of actions performed, skipped, or failed.
2224
* Pre-commit hooks configured for code quality and consistency.
@@ -67,7 +69,7 @@ strux [OPTIONS] <MARKDOWN_FILE>
6769
* **Default:** `./project-generated`. This path is relative to the **current working directory** where you run the command.
6870
* The directory will be created if it doesn't exist.
6971
* The command will fail if the specified path exists but is not a directory.
70-
* `-f`, `--force`: Overwrite existing files when a `File` or `Moved File` action targets a path that already exists as a file. Without this flag, existing files will be skipped. This flag does not allow replacing a directory with a file.
72+
* `-f`, `--force`: Overwrite existing files when a `File` or `Moved File` action targets a path that already exists as a file. Without this flag, existing files will be skipped. This flag does not allow replacing a directory with a file. It does not currently affect `Append File` or `Prepend File` actions beyond their standard behavior (they will operate on existing files or create new ones).
7173
* `-h`, `--help`: Print help information.
7274
* `-V`, `--version`: Print version information.
7375

@@ -104,7 +106,43 @@ fn main() {
104106
```
105107
````
106108

107-
**2. `Deleted File` Actions:**
109+
**2. `Append File` Actions:**
110+
111+
These headers must be immediately followed by a fenced code block. The content of the code block will be appended to the specified file. If the file does not exist, it will be created with the content.
112+
113+
* **Markdown Headers:**
114+
* `## Append File: path/to/your/file.txt`
115+
* `**Append File: path/to/your/file.txt**`
116+
117+
**Example (Append Header):**
118+
119+
````markdown
120+
## Append File: logs/app.log
121+
122+
```
123+
[INFO] Application started.
124+
```
125+
````
126+
127+
**3. `Prepend File` Actions:**
128+
129+
These headers must be immediately followed by a fenced code block. The content of the code block will be prepended to the specified file. If the file does not exist, it will be created with the content.
130+
131+
* **Markdown Headers:**
132+
* `## Prepend File: path/to/your/file.txt`
133+
* `**Prepend File: path/to/your/file.txt**`
134+
135+
**Example (Prepend Header):**
136+
137+
````markdown
138+
## Prepend File: config.ini
139+
140+
```ini
141+
; Generated by Strux
142+
```
143+
````
144+
145+
**4. `Deleted File` Actions:**
108146

109147
These headers define files to be deleted. They should *not* be followed by a code block unless using the special case below.
110148

@@ -122,7 +160,7 @@ path/inside/block_to_delete.tmp
122160
```
123161
````
124162

125-
**3. `Moved File` Actions:**
163+
**5. `Moved File` Actions:**
126164

127165
These headers define files to be moved. They should *not* be followed by a code block. The keyword " to " (case-sensitive, with spaces) separates the source and destination paths.
128166

@@ -138,9 +176,10 @@ These headers define files to be moved. They should *not* be followed by a code
138176
## Moved File: temp/report.docx to final/official_report.docx
139177
````
140178
141-
**4. Internal Comment Headers (Inside Code Blocks for `File` actions):**
179+
**6. Internal Comment Headers (Inside Code Blocks for `File`, `Append File`, `Prepend File` actions):**
142180
143-
These headers can appear on the *first line* inside a code block to define the file path for a `File` action.
181+
These headers can appear on the *first line* inside a code block to define the file path for a `File`, `Append File`, or `Prepend File` action.
182+
*Supported types: `File` (e.g., `// File: path/to/file.ext`). Support for `Append File` and `Prepend File` in this format may be added in the future.*
144183
145184
* `// File: path/to/file.ext`: The header line itself is **excluded** from the file content. Supports paths in backticks (`// File:\`path with spaces.txt\``).
146185
@@ -161,10 +200,10 @@ These headers can appear on the *first line* inside a code block to define the f
161200
162201
*Heuristics apply to avoid misinterpreting comments as paths.*
163202
164-
**5. Wrapped Headers:**
203+
**7. Wrapped Headers:**
165204
166205
A header can be placed inside a ` ```markdown ` or ` ```md ` block.
167-
* For `File` actions, it applies to the *next adjacent* code block.
206+
* For `File`, `Append File`, or `Prepend File` actions, it applies to the *next adjacent* code block.
168207
* For `Deleted File` or `Moved File` actions, it's a standalone action.
169208
170209
* **Create Example:**
@@ -180,6 +219,17 @@ A header can be placed inside a ` ```markdown ` or ` ```md ` block.
180219
feature_a: true
181220
```
182221
````
222+
* **Append Example:**
223+
224+
````markdown
225+
```markdown
226+
## Append File: notes.txt
227+
```
228+
229+
```
230+
Another note.
231+
```
232+
````
183233
184234
* **Delete Example:**
185235
@@ -201,14 +251,14 @@ A header can be placed inside a ` ```markdown ` or ` ```md ` block.
201251
### Path Handling and Safety
202252
203253
* Paths specified in headers are treated as relative to the `--output-dir`.
204-
* Parent directories are created automatically as needed for `File` actions and for the destination of `Moved File` actions.
254+
* Parent directories are created automatically as needed for `File`, `Append File`, `Prepend File` actions and for the destination of `Moved File` actions.
205255
* **Safety:** The tool prevents writing or moving files outside the resolved base output directory. Paths containing `..` that would escape the base directory will cause the action to fail safely.
206256
* Paths containing invalid components (like `//` or trailing `/`) will be skipped.
207257
208-
### Content Handling (for `File` actions)
258+
### Content Handling (for `File`, `Append File`, `Prepend File` actions)
209259
210-
* The *entire* content within the fenced code block (excluding the fences themselves and certain internal headers) is written to the file.
211-
* A trailing newline (`\n`) is added to the file content if it doesn't already end with one.
260+
* The *entire* content within the fenced code block (excluding the fences themselves and certain internal headers) is written to the file (or appended/prepended).
261+
* A trailing newline (`\n`) is added to this content chunk if it doesn't already end with one.
212262
213263
## Examples
214264
@@ -246,6 +296,11 @@ Generated by Strux.
246296
247297
## Moved File: temp/draft.txt to docs/final_draft.txt
248298
299+
## Append File: README.md
300+
```
301+
This content will be appended.
302+
```
303+
249304
```md
250305
## Deleted File: temp/to_delete.log
251306
```
@@ -270,7 +325,7 @@ strux -o my_project example.md
270325
```plaintext
271326
my_project/
272327
├── .gitignore
273-
├── README.md
328+
├── README.md # Content: "# My Project\nGenerated by Strux.\nThis content will be appended.\n"
274329
├── docs/
275330
│ └── final_draft.txt # Moved from temp/draft.txt
276331
└── src/
@@ -279,7 +334,7 @@ my_project/
279334
```
280335
281336
* `my_project/src/main.py` and `my_project/src/utils.py` contain their Python code.
282-
* `my_project/README.md` contains its content.
337+
* `my_project/README.md` contains its original content plus the appended text.
283338
* `my_project/.gitignore` contains its content.
284339
* `my_project/docs/final_draft.txt` contains "This is a draft file that will be moved.\n" (moved from `my_project/temp/draft.txt`).
285340
* The original `my_project/temp/draft.txt` is gone as it was moved.

src/cli/output.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@ pub fn print_summary(summary: &Summary, resolved_base: &Path) {
1515
" Files overwritten (--force): {}",
1616
summary.overwritten
1717
);
18+
println!(" Files appended: {}", summary.appended); // New
19+
println!(
20+
" Files prepended: {}",
21+
summary.prepended
22+
); // New
1823
println!(" Files deleted: {}", summary.deleted);
1924
println!(" Files moved: {}", summary.moved);
2025
println!(
@@ -63,6 +68,14 @@ pub fn print_summary(summary: &Summary, resolved_base: &Path) {
6368
" Failed (create, target is dir): {}",
6469
summary.failed_isdir_create
6570
);
71+
println!(
72+
" Failed (append, target is dir): {}", // New
73+
summary.failed_isdir_append
74+
);
75+
println!(
76+
" Failed (prepend, target is dir): {}", // New
77+
summary.failed_isdir_prepend
78+
);
6679
println!(
6780
" Failed (create, parent is file): {}",
6881
summary.failed_parent_isdir

src/constants.rs

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,19 +6,28 @@ use regex; // Needed for regex::escape
66
// --- Action Keywords ---
77
pub const ACTION_FILE: &str = "File";
88
pub const ACTION_DELETED_FILE: &str = "Deleted File";
9-
pub const ACTION_MOVED_FILE: &str = "Moved File"; // New action
9+
pub const ACTION_MOVED_FILE: &str = "Moved File";
10+
pub const ACTION_APPEND_FILE: &str = "Append File"; // New action
11+
pub const ACTION_PREPEND_FILE: &str = "Prepend File"; // New action
1012

1113
// --- Parsing ---
1214
pub const INTERNAL_COMMENT_ACTION_PREFIX: &str = "// File:";
15+
// Consider if we need // Append File: or // Prepend File: prefixes later. For now, stick to File.
1316

1417
// Helper to build the VALID_ACTIONS_REGEX string component once.
1518
// This is used by the regex definition in `parser::regex`.
1619
pub static VALID_ACTIONS_REGEX_STR: Lazy<String> = Lazy::new(|| {
17-
[ACTION_FILE, ACTION_DELETED_FILE, ACTION_MOVED_FILE] // Added ACTION_MOVED_FILE
18-
.iter()
19-
.map(|a| regex::escape(a))
20-
.collect::<Vec<_>>()
21-
.join("|")
20+
[
21+
ACTION_FILE,
22+
ACTION_DELETED_FILE,
23+
ACTION_MOVED_FILE,
24+
ACTION_APPEND_FILE, // Added
25+
ACTION_PREPEND_FILE, // Added
26+
]
27+
.iter()
28+
.map(|a| regex::escape(a))
29+
.collect::<Vec<_>>()
30+
.join("|")
2231
});
2332

2433
// Note: Regex objects themselves are defined in `src/parser/regex.rs`

src/core_types.rs

Lines changed: 30 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,17 @@
99
pub enum ActionType {
1010
Create,
1111
Delete,
12-
Move, // New action type
12+
Move,
13+
Append, // New action type
14+
Prepend, // New action type
1315
}
1416

1517
#[derive(Debug, Clone)]
1618
pub struct Action {
1719
pub action_type: ActionType,
18-
pub path: String, // Source path for Move, target path for Create/Delete
20+
pub path: String, // Source path for Move, target path for Create/Delete/Append/Prepend
1921
pub dest_path: Option<String>, // Destination path for Move
20-
pub content: Option<String>, // Content for Create
22+
pub content: Option<String>, // Content for Create/Append/Prepend
2123
pub original_pos: usize, // Byte offset in original markdown content
2224
}
2325

@@ -26,20 +28,24 @@ pub struct Summary {
2628
pub created: u32,
2729
pub overwritten: u32,
2830
pub deleted: u32,
29-
pub moved: u32, // New summary field
30-
pub moved_overwritten: u32, // New summary field
31+
pub moved: u32,
32+
pub moved_overwritten: u32,
33+
pub appended: u32, // New summary field
34+
pub prepended: u32, // New summary field
3135
pub skipped_exists: u32,
3236
pub skipped_not_found: u32,
3337
pub skipped_isdir_delete: u32,
3438
pub skipped_other_type: u32,
35-
pub skipped_move_src_not_found: u32, // New summary field
36-
pub skipped_move_src_is_dir: u32, // New summary field
37-
pub skipped_move_dst_exists: u32, // New summary field
38-
pub skipped_move_dst_isdir: u32, // New summary field
39+
pub skipped_move_src_not_found: u32,
40+
pub skipped_move_src_is_dir: u32,
41+
pub skipped_move_dst_exists: u32,
42+
pub skipped_move_dst_isdir: u32,
3943
pub failed_io: u32,
4044
pub failed_isdir_create: u32,
4145
pub failed_parent_isdir: u32,
4246
pub failed_unsafe: u32,
47+
pub failed_isdir_append: u32, // New summary field
48+
pub failed_isdir_prepend: u32, // New summary field
4349
pub error_other: u32,
4450
}
4551

@@ -62,9 +68,21 @@ pub enum DeleteStatus {
6268
#[derive(Debug, PartialEq, Eq)]
6369
pub enum MoveStatus {
6470
Moved,
65-
MovedOverwritten, // When --force is used and destination file is overwritten
71+
MovedOverwritten,
6672
SkippedSourceNotFound,
6773
SkippedSourceIsDir,
68-
SkippedDestinationExists, // When destination file exists and --force is not used
69-
SkippedDestinationIsDir, // When destination path is an existing directory
74+
SkippedDestinationExists,
75+
SkippedDestinationIsDir,
76+
}
77+
78+
#[derive(Debug, PartialEq, Eq)]
79+
pub enum AppendStatus {
80+
Appended, // Content was appended to an existing file
81+
Created, // File did not exist, so it was created with the content
82+
}
83+
84+
#[derive(Debug, PartialEq, Eq)]
85+
pub enum PrependStatus {
86+
Prepended, // Content was prepended to an existing file
87+
Created, // File did not exist, so it was created with the content
7088
}

src/errors.rs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -56,14 +56,16 @@ pub enum ProcessError {
5656
InvalidPathFormat { path: String },
5757
#[error("Cannot perform operation. Target path '{path}' exists and is a directory.")]
5858
TargetIsDirectory { path: PathBuf },
59-
// #[error("Cannot perform operation. Target path '{path}' is not a file.")]
60-
// TargetIsNotFile { path: PathBuf }, // Potentially for move source if not a file
6159
#[error(
6260
"Cannot create file '{path}'. Parent path '{parent_path}' exists but is not a directory."
6361
)]
6462
ParentIsNotDirectory { path: PathBuf, parent_path: PathBuf },
6563
#[error("Cannot move. Source path '{path}' is a directory, not a file.")]
66-
MoveSourceIsDir { path: PathBuf }, // New error for Move
64+
MoveSourceIsDir { path: PathBuf },
65+
#[error("Cannot append to path '{path}' because it exists and is a directory.")]
66+
TargetIsDirectoryForAppend { path: PathBuf }, // New error for Append
67+
#[error("Cannot prepend to path '{path}' because it exists and is a directory.")]
68+
TargetIsDirectoryForPrepend { path: PathBuf }, // New error for Prepend
6769
#[error("Unknown action type encountered")]
6870
UnknownAction, // Should not happen if parsing is correct
6971
#[error("Unexpected internal error: {0}")]

src/lib.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,10 @@ pub mod processor;
1212
// Re-export essential types/functions for easier use by the binary crate (main.rs)
1313
// or potentially other consumers of this library.
1414
pub use constants::*;
15-
pub use core_types::{Action, ActionType, CreateStatus, DeleteStatus, MoveStatus, Summary}; // Added MoveStatus
15+
pub use core_types::{
16+
Action, ActionType, AppendStatus, CreateStatus, DeleteStatus, MoveStatus, PrependStatus,
17+
Summary,
18+
}; // Added AppendStatus, PrependStatus
1619
pub use errors::{AppError, ParseError, ProcessError};
1720
pub use parser::parse_markdown;
1821
pub use processor::process_actions;

src/parser/header_utils.rs

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
//! Utilities for parsing markdown header lines (e.g., **Action: path**).
22
3-
use crate::constants::{ACTION_DELETED_FILE, ACTION_FILE, ACTION_MOVED_FILE};
3+
use crate::constants::{
4+
ACTION_APPEND_FILE, ACTION_DELETED_FILE, ACTION_FILE, ACTION_MOVED_FILE, ACTION_PREPEND_FILE,
5+
}; // Added new actions
46
use crate::core_types::ActionType;
57
use regex::Captures;
68

79
/// Represents the details extracted from a header line.
810
pub(crate) struct ParsedHeaderAction {
911
pub action_word: String,
10-
pub path: String, // Source path for Move, target path for File/Delete
12+
pub path: String, // Source path for Move, target path for File/Delete/Append/Prepend
1113
pub dest_path: Option<String>, // Destination path for Move
1214
}
1315

@@ -62,7 +64,8 @@ pub(crate) fn extract_header_action_details(caps: &Captures) -> Option<ParsedHea
6264
None // Failed to parse "source to dest"
6365
}
6466
} else {
65-
// For ACTION_FILE (including backtick-only cases) and ACTION_DELETED_FILE
67+
// For ACTION_FILE (including backtick-only cases), ACTION_DELETED_FILE,
68+
// ACTION_APPEND_FILE, ACTION_PREPEND_FILE
6669
if let Some(path) = parse_single_path_from_content(&raw_content) {
6770
if is_path_valid_for_action(&path) {
6871
Some(ParsedHeaderAction {
@@ -103,7 +106,8 @@ fn is_path_valid_for_action(path_str: &str) -> bool {
103106
}
104107

105108
/// Parses a single path from a content string (which might include backticks or trailing comments).
106-
/// Used for "File:" and "Deleted File:" actions, and for backtick-only path headers.
109+
/// Used for "File:", "Deleted File:", "Append File:", "Prepend File:" actions,
110+
/// and for backtick-only path headers.
107111
fn parse_single_path_from_content(raw_content: &str) -> Option<String> {
108112
let trimmed_content = raw_content.trim();
109113

@@ -198,7 +202,9 @@ pub(crate) fn get_action_type(action_word: &str) -> Option<ActionType> {
198202
match action_word {
199203
ACTION_FILE => Some(ActionType::Create),
200204
ACTION_DELETED_FILE => Some(ActionType::Delete),
201-
ACTION_MOVED_FILE => Some(ActionType::Move), // New mapping
205+
ACTION_MOVED_FILE => Some(ActionType::Move),
206+
ACTION_APPEND_FILE => Some(ActionType::Append), // New mapping
207+
ACTION_PREPEND_FILE => Some(ActionType::Prepend), // New mapping
202208
_ => None,
203209
}
204210
}

0 commit comments

Comments
 (0)