Skip to main content

silver_platter/
codemod.rs

1//! Codemod
2use breezyshim::error::Error as BrzError;
3use breezyshim::RevisionId;
4use breezyshim::WorkingTree;
5use std::collections::HashMap;
6use url::Url;
7
8#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
9/// Command result
10pub struct CommandResult {
11    /// Value
12    pub value: Option<u32>,
13
14    /// Context
15    pub context: Option<serde_json::Value>,
16
17    /// Description
18    pub description: Option<String>,
19
20    /// Serialized context
21    pub serialized_context: Option<String>,
22
23    /// Commit message
24    pub commit_message: Option<String>,
25
26    /// Title
27    pub title: Option<String>,
28
29    /// Tags
30    pub tags: Vec<(String, Option<RevisionId>)>,
31
32    /// Target branch URL
33    pub target_branch_url: Option<Url>,
34
35    /// Old revision
36    pub old_revision: RevisionId,
37
38    /// New revision
39    pub new_revision: RevisionId,
40}
41
42impl Default for CommandResult {
43    fn default() -> Self {
44        Self {
45            value: None,
46            context: None,
47            description: None,
48            serialized_context: None,
49            commit_message: None,
50            title: None,
51            tags: Vec::new(),
52            target_branch_url: None,
53            old_revision: RevisionId::null(),
54            new_revision: RevisionId::null(),
55        }
56    }
57}
58
59impl crate::CodemodResult for CommandResult {
60    fn context(&self) -> serde_json::Value {
61        self.context.clone().unwrap_or_default()
62    }
63
64    fn value(&self) -> Option<u32> {
65        self.value
66    }
67
68    fn target_branch_url(&self) -> Option<Url> {
69        self.target_branch_url.clone()
70    }
71
72    fn description(&self) -> Option<String> {
73        self.description.clone()
74    }
75
76    fn tags(&self) -> Vec<(String, Option<RevisionId>)> {
77        self.tags.clone()
78    }
79}
80
81impl From<CommandResult> for DetailedSuccess {
82    fn from(r: CommandResult) -> Self {
83        DetailedSuccess {
84            value: r.value,
85            context: r.context,
86            description: r.description,
87            commit_message: r.commit_message,
88            title: r.title,
89            serialized_context: r.serialized_context,
90            tags: Some(
91                r.tags
92                    .into_iter()
93                    .map(|(k, v)| (k, v.map(|v| v.to_string())))
94                    .collect(),
95            ),
96            target_branch_url: r.target_branch_url,
97        }
98    }
99}
100
101impl From<&CommandResult> for DetailedSuccess {
102    fn from(r: &CommandResult) -> Self {
103        DetailedSuccess {
104            value: r.value,
105            context: r.context.clone(),
106            description: r.description.clone(),
107            commit_message: r.commit_message.clone(),
108            title: r.title.clone(),
109            serialized_context: r.serialized_context.clone(),
110            tags: Some(
111                r.tags
112                    .iter()
113                    .map(|(k, v)| (k.clone(), v.as_ref().map(|v| v.to_string())))
114                    .collect(),
115            ),
116            target_branch_url: r.target_branch_url.clone(),
117        }
118    }
119}
120
121#[derive(Debug, serde::Deserialize, serde::Serialize, Default)]
122struct DetailedSuccess {
123    value: Option<u32>,
124    context: Option<serde_json::Value>,
125    description: Option<String>,
126    serialized_context: Option<String>,
127    #[serde(rename = "commit-message")]
128    commit_message: Option<String>,
129    title: Option<String>,
130    tags: Option<Vec<(String, Option<String>)>>,
131    #[serde(rename = "target-branch-url")]
132    target_branch_url: Option<Url>,
133}
134
135#[derive(Debug)]
136/// Error while running codemod
137pub enum Error {
138    /// Script made no changes
139    ScriptMadeNoChanges,
140
141    /// Script was not found
142    ScriptNotFound,
143
144    /// The script failed with a specific exit code
145    ExitCode(i32),
146
147    /// Detailed failure
148    Detailed(DetailedFailure),
149
150    /// I/O error
151    Io(std::io::Error),
152
153    /// JSON error
154    Json(serde_json::Error),
155
156    /// UTF-8 error
157    Utf8(std::string::FromUtf8Error),
158
159    /// Other error
160    Other(String),
161}
162
163impl From<std::io::Error> for Error {
164    fn from(e: std::io::Error) -> Self {
165        Error::Io(e)
166    }
167}
168
169impl From<serde_json::Error> for Error {
170    fn from(e: serde_json::Error) -> Self {
171        Error::Json(e)
172    }
173}
174
175impl From<std::string::FromUtf8Error> for Error {
176    fn from(e: std::string::FromUtf8Error) -> Self {
177        Error::Utf8(e)
178    }
179}
180
181impl std::fmt::Display for Error {
182    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
183        match self {
184            Error::ScriptMadeNoChanges => write!(f, "Script made no changes"),
185            Error::ScriptNotFound => write!(f, "Script not found"),
186            Error::ExitCode(code) => write!(f, "Script exited with code {}", code),
187            Error::Detailed(d) => write!(f, "Script failed: {:?}", d),
188            Error::Io(e) => write!(f, "Command failed: {}", e),
189            Error::Json(e) => write!(f, "JSON error: {}", e),
190            Error::Utf8(e) => write!(f, "UTF-8 error: {}", e),
191            Error::Other(s) => write!(f, "{}", s),
192        }
193    }
194}
195
196impl std::error::Error for Error {}
197
198#[derive(Debug, serde::Deserialize, serde::Serialize, Clone, PartialEq, Eq)]
199/// Detailed failure information
200pub struct DetailedFailure {
201    /// Result code
202    pub result_code: String,
203
204    /// Description of the failure
205    pub description: Option<String>,
206
207    /// Stage at which the failure occurred
208    pub stage: Option<Vec<String>>,
209
210    /// Additional details
211    pub details: Option<serde_json::Value>,
212}
213
214impl std::fmt::Display for DetailedFailure {
215    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
216        write!(f, "Script failed: {}", self.result_code)?;
217        if let Some(description) = &self.description {
218            write!(f, ": {}", description)?;
219        }
220        if let Some(stage) = &self.stage {
221            write!(f, " (stage: {})", stage.join(" "))?;
222        }
223        if let Some(details) = &self.details {
224            write!(f, ": {:?}", details)?;
225        }
226        Ok(())
227    }
228}
229
230/// Run a script in a tree and commit the result.
231///
232/// This ignores newly added files.
233///
234/// # Arguments
235///
236/// - `local_tree`: Local tree to run script in
237/// - `subpath`: Subpath to run script in
238/// - `script`: Script to run
239/// - `commit_pending`: Whether to commit pending changes
240pub fn script_runner(
241    local_tree: &dyn WorkingTree,
242    script: &[&str],
243    subpath: &std::path::Path,
244    commit_pending: crate::CommitPending,
245    resume_metadata: Option<&serde_json::Value>,
246    committer: Option<&str>,
247    extra_env: Option<HashMap<String, String>>,
248    stderr: std::process::Stdio,
249) -> Result<CommandResult, Error> {
250    let mut env = std::env::vars().collect::<HashMap<_, _>>();
251
252    if let Some(extra_env) = extra_env {
253        for (k, v) in extra_env {
254            env.insert(k, v);
255        }
256    }
257
258    env.insert("SVP_API".to_string(), "1".to_string());
259
260    let last_revision = local_tree.last_revision().unwrap();
261
262    let mut orig_tags = local_tree.get_tag_dict().unwrap();
263
264    let td = tempfile::tempdir()?;
265
266    let result_path = td.path().join("result.json");
267    env.insert(
268        "SVP_RESULT".to_string(),
269        result_path.to_string_lossy().to_string(),
270    );
271    if let Some(resume_metadata) = resume_metadata {
272        let resume_path = td.path().join("resume.json");
273        env.insert(
274            "SVP_RESUME".to_string(),
275            resume_path.to_string_lossy().to_string(),
276        );
277        let w = std::fs::File::create(&resume_path)?;
278        serde_json::to_writer(w, &resume_metadata)?;
279    }
280
281    let mut command = std::process::Command::new(script[0]);
282    command.args(&script[1..]);
283    command.envs(env);
284    command.stdin(std::process::Stdio::null());
285    command.stdout(std::process::Stdio::piped());
286    command.stderr(stderr);
287    command.current_dir(local_tree.abspath(subpath).unwrap());
288
289    let ret = match command.output() {
290        Ok(ret) => ret,
291        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
292            return Err(Error::ScriptNotFound);
293        }
294        Err(e) => {
295            return Err(Error::Io(e));
296        }
297    };
298
299    if !ret.status.success() {
300        return Err(match std::fs::read_to_string(&result_path) {
301            Ok(result) => {
302                let result: DetailedFailure = serde_json::from_str(&result)?;
303                Error::Detailed(result)
304            }
305            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
306                Error::ExitCode(ret.status.code().unwrap_or(1))
307            }
308            Err(_) => Error::ExitCode(ret.status.code().unwrap_or(1)),
309        });
310    }
311
312    // Open result_path, read metadata
313    let mut result: DetailedSuccess = match std::fs::read_to_string(&result_path) {
314        Ok(result) => serde_json::from_str(&result)?,
315        Err(e) if e.kind() == std::io::ErrorKind::NotFound => DetailedSuccess::default(),
316        Err(e) => return Err(e.into()),
317    };
318
319    if result.description.is_none() {
320        result.description = Some(String::from_utf8(ret.stdout)?);
321    }
322
323    let mut new_revision = local_tree.last_revision().unwrap();
324    let tags: Vec<(String, Option<RevisionId>)> = if let Some(tags) = result.tags {
325        tags.into_iter()
326            .map(|(n, v)| (n, v.map(|v| RevisionId::from(v.as_bytes().to_vec()))))
327            .collect()
328    } else {
329        let mut tags = local_tree
330            .get_tag_dict()
331            .unwrap()
332            .into_iter()
333            .filter_map(|(n, v)| {
334                if orig_tags.remove(n.as_str()).as_ref() != Some(&v) {
335                    Some((n, Some(v)))
336                } else {
337                    None
338                }
339            })
340            .collect::<Vec<_>>();
341        tags.extend(orig_tags.into_keys().map(|n| (n, None)));
342        tags
343    };
344
345    let commit_pending = match commit_pending {
346        crate::CommitPending::Auto => {
347            // Automatically commit pending changes if the script did not
348            // touch the branch
349            last_revision == new_revision
350        }
351        crate::CommitPending::Yes => true,
352        crate::CommitPending::No => false,
353    };
354
355    if commit_pending {
356        local_tree
357            .smart_add(&[local_tree.abspath(subpath).unwrap().as_path()])
358            .unwrap();
359        let mut builder = local_tree
360            .build_commit()
361            .message(result.description.as_ref().unwrap())
362            .allow_pointless(false);
363        if let Some(committer) = committer {
364            builder = builder.committer(committer);
365        }
366        match builder.commit() {
367            Ok(rev) => {
368                new_revision = rev;
369            }
370            Err(BrzError::PointlessCommit) => {
371                // No changes - keep new_revision as last_revision
372            }
373            Err(e) => return Err(Error::Other(format!("Failed to commit changes: {}", e))),
374        };
375    }
376
377    if new_revision == last_revision {
378        return Err(Error::ScriptMadeNoChanges);
379    }
380
381    let old_revision = last_revision;
382    let new_revision = local_tree.last_revision().unwrap();
383
384    Ok(CommandResult {
385        old_revision,
386        new_revision,
387        tags,
388        description: result.description,
389        value: result.value,
390        context: result.context,
391        commit_message: result.commit_message,
392        title: result.title,
393        serialized_context: result.serialized_context,
394        target_branch_url: result.target_branch_url,
395    })
396}
397
398#[cfg(test)]
399mod command_result_tests {
400    use super::*;
401    use crate::CodemodResult;
402
403    #[test]
404    fn test_command_result_context_with_value() {
405        let result = CommandResult {
406            context: Some(serde_json::json!({"key": "value"})),
407            ..Default::default()
408        };
409
410        assert_eq!(result.context(), serde_json::json!({"key": "value"}));
411    }
412
413    #[test]
414    fn test_command_result_context_none() {
415        let result = CommandResult {
416            context: None,
417            ..Default::default()
418        };
419
420        // Should return default (null) when context is None
421        assert_eq!(result.context(), serde_json::Value::Null);
422    }
423
424    #[test]
425    fn test_command_result_value() {
426        let result = CommandResult {
427            value: Some(42),
428            ..Default::default()
429        };
430
431        assert_eq!(result.value(), Some(42));
432    }
433
434    #[test]
435    fn test_command_result_description() {
436        let result = CommandResult {
437            description: Some("Test description".to_string()),
438            ..Default::default()
439        };
440
441        assert_eq!(result.description(), Some("Test description".to_string()));
442    }
443
444    #[test]
445    fn test_command_result_description_none() {
446        let result = CommandResult {
447            description: None,
448            ..Default::default()
449        };
450
451        assert_eq!(result.description(), None);
452    }
453
454    #[test]
455    fn test_command_result_target_branch_url() {
456        let url = url::Url::parse("https://github.com/test/repo").unwrap();
457        let result = CommandResult {
458            target_branch_url: Some(url.clone()),
459            ..Default::default()
460        };
461
462        assert_eq!(result.target_branch_url(), Some(url));
463    }
464
465    #[test]
466    fn test_command_result_tags() {
467        let tags = vec![
468            ("v1.0".to_string(), Some(RevisionId::from(b"rev1".to_vec()))),
469            ("v2.0".to_string(), None),
470        ];
471        let result = CommandResult {
472            tags: tags.clone(),
473            ..Default::default()
474        };
475
476        assert_eq!(result.tags(), tags);
477    }
478
479    #[test]
480    fn test_command_result_default() {
481        let result = CommandResult::default();
482
483        assert_eq!(result.context(), serde_json::Value::Null);
484        assert_eq!(result.value(), None);
485        assert_eq!(result.target_branch_url(), None);
486        assert_eq!(result.description(), None);
487        assert!(result.tags().is_empty());
488    }
489}
490
491#[cfg(test)]
492mod script_runner_tests {
493    use breezyshim::controldir::create_standalone_workingtree;
494    use breezyshim::testing::TestEnv;
495    use breezyshim::tree::MutableTree;
496    use breezyshim::WorkingTree;
497    use serial_test::serial;
498
499    fn make_executable(script_path: &std::path::Path) {
500        #[cfg(unix)]
501        {
502            use std::os::unix::fs::PermissionsExt;
503            // Make script.sh executable
504            let mut perm = std::fs::metadata(script_path).unwrap().permissions();
505            perm.set_mode(0o755);
506            std::fs::set_permissions(script_path, perm).unwrap();
507        }
508    }
509
510    #[test]
511    #[serial]
512    fn test_no_api() {
513        let _test_env = TestEnv::new();
514        let td = tempfile::tempdir().unwrap();
515        let d = td.path().join("t");
516        let tree = create_standalone_workingtree(&d, "bzr").unwrap();
517        let script_path = td.path().join("script.sh");
518        std::fs::write(
519            &script_path,
520            r#"#!/bin/sh
521echo foo > bar
522echo Did a thing
523"#,
524        )
525        .unwrap();
526
527        make_executable(&script_path);
528
529        std::fs::write(d.join("bar"), "bar").unwrap();
530
531        tree.add(&[std::path::Path::new("bar")]).unwrap();
532        let old_revid = tree.build_commit().message("initial").commit().unwrap();
533        let script_path_str = script_path.to_str().unwrap();
534        let result = super::script_runner(
535            &tree,
536            &[script_path_str],
537            std::path::Path::new(""),
538            crate::CommitPending::Auto,
539            None,
540            Some("Joe Example <joe@example.com>"),
541            None,
542            std::process::Stdio::null(),
543        )
544        .unwrap();
545
546        assert!(!tree.has_changes().unwrap());
547        assert_eq!(result.old_revision, old_revid);
548        assert_eq!(result.new_revision, tree.last_revision().unwrap());
549        assert_eq!(result.description.as_deref().unwrap(), "Did a thing\n");
550
551        std::mem::drop(td);
552    }
553
554    #[test]
555    #[serial]
556    fn test_api() {
557        let _test_env = TestEnv::new();
558        let td = tempfile::tempdir().unwrap();
559        let d = td.path().join("t");
560        let tree = create_standalone_workingtree(&d, "bzr").unwrap();
561        let script_path = td.path().join("script.sh");
562        std::fs::write(
563            &script_path,
564            r#"#!/bin/sh
565echo foo > bar
566echo '{"description": "Did a thing", "code": "success"}' > $SVP_RESULT
567"#,
568        )
569        .unwrap();
570
571        make_executable(&script_path);
572
573        std::fs::write(d.join("bar"), "bar").unwrap();
574
575        tree.add(&[std::path::Path::new("bar")]).unwrap();
576        let old_revid = tree.build_commit().message("initial").commit().unwrap();
577        let script_path_str = script_path.to_str().unwrap();
578        let result = super::script_runner(
579            &tree,
580            &[script_path_str],
581            std::path::Path::new(""),
582            crate::CommitPending::Auto,
583            None,
584            Some("Joe Example <joe@example.com>"),
585            None,
586            std::process::Stdio::null(),
587        )
588        .unwrap();
589
590        assert!(!tree.has_changes().unwrap());
591        assert_eq!(result.old_revision, old_revid);
592        assert_eq!(result.new_revision, tree.last_revision().unwrap());
593        assert_eq!(result.description.as_deref().unwrap(), "Did a thing");
594
595        std::mem::drop(td);
596    }
597
598    #[test]
599    #[serial]
600    fn test_new_file() {
601        let _test_env = TestEnv::new();
602        let td = tempfile::tempdir().unwrap();
603        let d = td.path().join("t");
604        let tree = create_standalone_workingtree(&d, "bzr").unwrap();
605        let script_path = d.join("script.sh");
606        std::fs::write(
607            &script_path,
608            r#"#!/bin/sh
609echo foo > bar
610echo Did a thing
611"#,
612        )
613        .unwrap();
614
615        make_executable(&script_path);
616
617        std::fs::write(d.join("bar"), "initial").unwrap();
618
619        tree.add(&[std::path::Path::new("bar")]).unwrap();
620        let old_revid = tree.build_commit().message("initial").commit().unwrap();
621
622        let script_path_str = script_path.to_str().unwrap();
623        let result = super::script_runner(
624            &tree,
625            &[script_path_str],
626            std::path::Path::new(""),
627            crate::CommitPending::Auto,
628            None,
629            Some("Joe Example <joe@example.com>"),
630            None,
631            std::process::Stdio::null(),
632        )
633        .unwrap();
634
635        assert!(!tree.has_changes().unwrap());
636        assert_eq!(result.old_revision, old_revid);
637        assert_eq!(result.new_revision, tree.last_revision().unwrap());
638        assert_eq!(result.description.as_deref().unwrap(), "Did a thing\n");
639
640        std::mem::drop(td);
641    }
642
643    #[test]
644    #[serial]
645    fn test_no_changes() {
646        let _test_env = TestEnv::new();
647        let td = tempfile::tempdir().unwrap();
648        let d = td.path().join("t");
649        let tree =
650            create_standalone_workingtree(&d, &breezyshim::controldir::ControlDirFormat::default())
651                .unwrap();
652        let script_path = td.path().join("script.sh");
653        std::fs::write(
654            &script_path,
655            r#"#!/bin/sh
656echo Did a thing
657"#,
658        )
659        .unwrap();
660
661        make_executable(&script_path);
662
663        tree.build_commit()
664            .message("initial")
665            .allow_pointless(true)
666            .commit()
667            .unwrap();
668        let script_path_str = script_path.to_str().unwrap();
669        let err = super::script_runner(
670            &tree,
671            &[script_path_str],
672            std::path::Path::new(""),
673            crate::CommitPending::Yes,
674            None,
675            Some("Joe Example <joe@example.com>"),
676            None,
677            std::process::Stdio::null(),
678        )
679        .unwrap_err();
680
681        assert!(!tree.has_changes().unwrap());
682        assert!(matches!(err, super::Error::ScriptMadeNoChanges));
683
684        std::mem::drop(td);
685    }
686}