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
//! Convert legacy bash scripts to installer.toml format (#115)
#![allow(clippy::indexing_slicing)] // Safe: bounds checked in while loop
//!
//! This module implements `bashrs installer from-bash` which converts
//! existing bash installation scripts to the declarative installer.toml format.
//!
//! # Handled Patterns
//!
//! - Array syntax (#103) → converted to TOML lists
//! - Case statements (#99) → converted to step conditions
//! - Heredocs (#96) → converted to template files
//! - sudo patterns (#100, #101) → converted to privileged actions
//! - inline if/then (#93) → converted to step preconditions
//!
//! # Example
//!
//! ```bash
//! # Input: install.sh
//! if [ "$EUID" -ne 0 ]; then echo "Run as root"; exit 1; fi
//! apt-get update
//! apt-get install -y docker-ce
//! ```
//!
//! ```toml
//! # Output: installer.toml
//! [installer.requirements]
//! privileges = "root"
//!
//! [[step]]
//! id = "update-packages"
//! action = "apt-update"
//!
//! [[step]]
//! id = "install-docker"
//! action = "apt-install"
//! packages = ["docker-ce"]
//! ```
use crate::models::{Error, Result};
use std::path::Path;
/// Result of converting a bash script to installer format
#[derive(Debug, Clone)]
pub struct ConversionResult {
/// Generated installer.toml content
pub installer_toml: String,
/// Template files extracted from heredocs
pub templates: Vec<TemplateFile>,
/// Warnings about patterns that couldn't be converted
pub warnings: Vec<String>,
/// Statistics about the conversion
pub stats: ConversionStats,
}
/// A template file extracted from a heredoc
#[derive(Debug, Clone)]
pub struct TemplateFile {
/// Filename for the template
pub name: String,
/// Content of the template
pub content: String,
}
/// Statistics about the conversion process
#[derive(Debug, Clone, Default)]
pub struct ConversionStats {
/// Number of steps generated
pub steps_generated: usize,
/// Number of apt-install commands found
pub apt_installs: usize,
/// Number of heredocs converted to templates
pub heredocs_converted: usize,
/// Number of sudo patterns converted
pub sudo_patterns: usize,
/// Number of conditionals converted to preconditions
pub conditionals_converted: usize,
}
/// Detected bash pattern that can be converted
#[derive(Debug, Clone, PartialEq)]
pub(crate) enum BashPattern {
/// Root check: if [ "$EUID" -ne 0 ]
RootCheck,
/// apt-get update
AptUpdate,
/// apt-get install -y packages...
AptInstall { packages: Vec<String> },
/// mkdir -p directory
MkdirP { path: String },
/// curl/wget download
Download { url: String, output: Option<String> },
/// Heredoc content
Heredoc { delimiter: String, content: String },
/// sudo command
SudoCommand { command: String },
/// Generic script line
Script { content: String },
}
/// Convert a bash script to installer.toml format
///
/// # Arguments
/// * `script` - The bash script content
/// * `name` - Name for the installer
///
/// # Returns
/// * `ConversionResult` with the generated installer.toml and any templates
pub fn convert_bash_to_installer(script: &str, name: &str) -> Result<ConversionResult> {
let patterns = extract_patterns(script)?;
let (toml, templates, stats) = generate_installer_toml(&patterns, name)?;
let warnings = generate_warnings(&patterns);
Ok(ConversionResult {
installer_toml: toml,
templates,
warnings,
stats,
})
}
/// Extract recognizable patterns from a bash script
fn extract_patterns(script: &str) -> Result<Vec<BashPattern>> {
let mut patterns = Vec::new();
let lines: Vec<&str> = script.lines().collect();
let mut i = 0;
while i < lines.len() {
let line = lines[i].trim();
// Skip empty lines and comments
if line.is_empty() || line.starts_with('#') {
i += 1;
continue;
}
// Check for root/EUID check
if line.contains("EUID") && line.contains("-ne 0") {
patterns.push(BashPattern::RootCheck);
i += 1;
continue;
}
// Check for apt-get update
if line.contains("apt-get update") || line.contains("apt update") {
patterns.push(BashPattern::AptUpdate);
i += 1;
continue;
}
// Check for apt-get install
if let Some(packages) = parse_apt_install(line) {
patterns.push(BashPattern::AptInstall { packages });
i += 1;
continue;
}
// Check for mkdir -p
if let Some(path) = parse_mkdir_p(line) {
patterns.push(BashPattern::MkdirP { path });
i += 1;
continue;
}
// Check for curl/wget download
if let Some((url, output)) = parse_download(line) {
patterns.push(BashPattern::Download { url, output });
i += 1;
continue;
}
// Check for heredoc
if let Some((delimiter, content, lines_consumed)) = parse_heredoc(&lines, i) {
patterns.push(BashPattern::Heredoc { delimiter, content });
i += lines_consumed;
continue;
}
// Check for sudo command
if let Some(command) = parse_sudo(line) {
patterns.push(BashPattern::SudoCommand { command });
i += 1;
continue;
}
// Default: treat as generic script line
patterns.push(BashPattern::Script {
content: line.to_string(),
});
i += 1;
}
Ok(patterns)
}
/// Parse apt-get install command and extract packages
fn parse_apt_install(line: &str) -> Option<Vec<String>> {
// Match: apt-get install -y pkg1 pkg2 ... or apt install -y pkg1 pkg2
let line = line.trim();
// Remove sudo prefix if present
let line = line.strip_prefix("sudo ").unwrap_or(line);
if line.starts_with("apt-get install") || line.starts_with("apt install") {
let parts: Vec<&str> = line.split_whitespace().collect();
let packages: Vec<String> = parts
.iter()
.skip(2) // Skip "apt-get" and "install" or "apt" and "install"
.filter(|p| !p.starts_with('-')) // Skip flags like -y
.map(|p| p.to_string())
.collect();
if !packages.is_empty() {
return Some(packages);
}
}
None
}
/// Parse mkdir -p command
fn parse_mkdir_p(line: &str) -> Option<String> {
let line = line.strip_prefix("sudo ").unwrap_or(line);
if line.starts_with("mkdir -p ") {
let path = line.strip_prefix("mkdir -p ")?.trim();
return Some(path.to_string());
}
None
}
/// Parse curl/wget download command
fn parse_download(line: &str) -> Option<(String, Option<String>)> {
let line = line.strip_prefix("sudo ").unwrap_or(line);
// curl -fsSL URL -o OUTPUT or curl -fsSL URL > OUTPUT
if line.starts_with("curl ") {
// Extract URL (simplified - looks for http/https)
let parts: Vec<&str> = line.split_whitespace().collect();
for (i, part) in parts.iter().enumerate() {
if part.starts_with("http://") || part.starts_with("https://") {
let url = part.to_string();
// Check for -o flag
let output = parts
.get(i + 2)
.filter(|_| parts.get(i + 1) == Some(&"-o"))
.map(|s| s.to_string());
return Some((url, output));
}
}
}
// wget URL -O OUTPUT
if line.starts_with("wget ") {
let parts: Vec<&str> = line.split_whitespace().collect();
for (i, part) in parts.iter().enumerate() {
if part.starts_with("http://") || part.starts_with("https://") {
let url = part.to_string();
let output = parts
.get(i + 2)
.filter(|_| parts.get(i + 1) == Some(&"-O"))
.map(|s| s.to_string());
return Some((url, output));
}
}
}
None
}
/// Parse heredoc and extract content
fn parse_heredoc(lines: &[&str], start: usize) -> Option<(String, String, usize)> {
let line = lines[start].trim();
// Match: cat << EOF or cat << 'EOF' or cat <<- EOF
if !line.contains("<<") {
return None;
}
// Extract delimiter
let after_heredoc = line.split("<<").nth(1)?;
let delimiter = after_heredoc
.trim()
.trim_start_matches('-')
.trim()
.trim_matches('\'')
.trim_matches('"')
.split_whitespace()
.next()?
.to_string();
// Collect content until we find the delimiter
let mut content = String::new();
let mut lines_consumed = 1;
for line in lines.iter().skip(start + 1) {
lines_consumed += 1;
if line.trim() == delimiter {
break;
}
content.push_str(line);
content.push('\n');
}
Some((delimiter, content, lines_consumed))
}
/// Parse sudo command
fn parse_sudo(line: &str) -> Option<String> {
if line.starts_with("sudo ") {
let command = line.strip_prefix("sudo ")?.to_string();
// Don't return if it's already handled by other parsers
if !command.starts_with("apt") && !command.starts_with("mkdir") {
return Some(command);
}
}
None
}
/// Generate installer.toml from extracted patterns
include!("from_bash_generate_2.rs");