ephemeris_files/
formatting.rs1use std::collections::BTreeMap;
4use std::process::{Command, ExitStatus};
5use std::{env, fs, io};
6
7use crate::error::IoResultExt as _;
8use crate::path::RepositoryPath;
9use crate::state::FormatterSpec;
10use crate::{FilesError, PathError};
11
12#[derive(Debug, thiserror::Error)]
14pub enum FormatterError {
15 #[error("per-file formatter overrides are not allowed for partial documents at `{path}`")]
17 PartialOverride {
18 path: RepositoryPath,
20 },
21 #[error("empty formatter command for `{path}`")]
23 EmptyCommand {
24 path: RepositoryPath,
26 },
27 #[error("formatter project root file `{project_root_file}` is unavailable for `{path}`")]
29 MissingInput {
30 path: RepositoryPath,
32 project_root_file: RepositoryPath,
34 },
35 #[error("could not run formatter `{command}` for `{path}`")]
37 Spawn {
38 path: RepositoryPath,
40 command: String,
42 #[source]
44 source: io::Error,
45 },
46 #[error("formatter `{command}` failed for `{path}` with status {status}: {stderr}")]
48 Failed {
49 path: RepositoryPath,
51 command: String,
53 status: ExitStatus,
55 stderr: String,
57 },
58}
59
60pub(crate) fn format(
62 path: &RepositoryPath,
63 candidate: &[u8],
64 formatter: &FormatterSpec,
65 assembled_inputs: &BTreeMap<RepositoryPath, Vec<u8>>,
66) -> Result<Vec<u8>, FilesError> {
67 if formatter.command.is_empty() {
68 return Err(FormatterError::EmptyCommand { path: path.clone() }.into());
69 }
70 let temporary = tempfile::tempdir().at_path(env::temp_dir())?;
71 let root = temporary.path();
72 for (input, content) in assembled_inputs {
73 let destination = root.join(input.as_path());
74 let Some(parent) = destination.parent() else {
75 return Err(PathError::Outside(destination).into());
76 };
77 fs::create_dir_all(parent).at_path(parent)?;
78 fs::write(&destination, content).at_path(&destination)?;
79 }
80 let project_root = root.join(formatter.project_root_file.as_path());
81 if !project_root.is_file() {
82 return Err(FormatterError::MissingInput {
83 path: path.clone(),
84 project_root_file: formatter.project_root_file.clone(),
85 }
86 .into());
87 }
88 let target = root.join(path.as_path());
89 if let Some(parent) = target.parent() {
90 fs::create_dir_all(parent).at_path(parent)?;
91 }
92 fs::write(&target, candidate).at_path(&target)?;
93 let mut command = Command::new(&formatter.command);
94 command.args(&formatter.args).arg(path.as_path());
95 let output = command
96 .current_dir(root)
97 .output()
98 .map_err(|source| FormatterError::Spawn {
99 path: path.clone(),
100 command: formatter.command.clone(),
101 source,
102 })?;
103 if !output.status.success() {
104 return Err(FormatterError::Failed {
105 path: path.clone(),
106 command: formatter.command.clone(),
107 status: output.status,
108 stderr: String::from_utf8_lossy(&output.stderr).trim().to_owned(),
109 }
110 .into());
111 }
112 fs::read(&target).at_path(&target)
113}
114
115#[cfg(test)]
116mod tests {
117 use std::collections::BTreeMap;
118
119 use super::format;
120 use crate::path::RepositoryPath;
121 use crate::state::FormatterSpec;
122 use crate::{FilesError, FormatterError};
123
124 #[test]
125 fn formats_candidate_with_planner_assembled_inputs() {
126 let spec = facet_json::from_str::<FormatterSpec>(
127 r#"{"command":"sh","args":["-c","grep -q desired formatter.conf && printf 'bar\\n' > \"$1\"","formatter"],"inputs":["formatter.conf"],"project_root_file":"formatter.conf"}"#,
128 )
129 .unwrap();
130 let inputs = [(
131 RepositoryPath::try_from("formatter.conf").unwrap(),
132 b"desired\n".to_vec(),
133 )]
134 .into();
135 let output = format(
136 &RepositoryPath::try_from("nested/file.txt").unwrap(),
137 b"foo\n",
138 &spec,
139 &inputs,
140 )
141 .expect("formatter succeeds");
142 assert_eq!(output, b"bar\n");
143 }
144
145 #[test]
146 fn formatter_failure_is_an_error() {
147 let spec = facet_json::from_str::<FormatterSpec>(
148 r#"{"command":"sh","args":["-c","exit 7","formatter"],"inputs":[],"project_root_file":"file.txt"}"#,
149 )
150 .unwrap();
151 let path = RepositoryPath::try_from("file.txt").unwrap();
152 let inputs = [(path.clone(), b"root\n".to_vec())].into();
153 assert!(matches!(
154 format(&path, b"content", &spec, &inputs),
155 Err(FilesError::Formatter(FormatterError::Failed { path: failed, command, status, stderr: _ }))
156 if failed == path && command == "sh" && status.code() == Some(7)
157 ));
158 }
159
160 #[test]
161 fn an_empty_formatter_command_is_rejected() {
162 let spec = facet_json::from_str::<FormatterSpec>(
163 r#"{"command":"","args":[],"inputs":[],"project_root_file":"file.txt"}"#,
164 )
165 .unwrap();
166 let path = RepositoryPath::try_from("file.txt").unwrap();
167 assert!(matches!(
168 format(&path, b"content", &spec, &BTreeMap::new()),
169 Err(FilesError::Formatter(FormatterError::EmptyCommand { path: rejected })) if rejected == path
170 ));
171 }
172
173 #[test]
174 fn a_missing_project_root_file_is_rejected() {
175 let spec = facet_json::from_str::<FormatterSpec>(
176 r#"{"command":"true","args":[],"inputs":[],"project_root_file":"flake.nix"}"#,
177 )
178 .unwrap();
179 let path = RepositoryPath::try_from("file.txt").unwrap();
180 assert!(matches!(
181 format(&path, b"content", &spec, &BTreeMap::new()),
182 Err(FilesError::Formatter(FormatterError::MissingInput { path: rejected, project_root_file }))
183 if rejected == path && project_root_file.to_string() == "flake.nix"
184 ));
185 }
186}