##// END OF EJS Templates
rhg: Don’t make repository path absolute too early...
Simon Sapin -
r47474:97ac588b default
parent child Browse files
Show More
@@ -1,196 +1,201 b''
1 1 use crate::config::ConfigValueParseError;
2 2 use std::fmt;
3 3
4 4 /// Common error cases that can happen in many different APIs
5 5 #[derive(Debug, derive_more::From)]
6 6 pub enum HgError {
7 7 IoError {
8 8 error: std::io::Error,
9 9 context: IoErrorContext,
10 10 },
11 11
12 12 /// A file under `.hg/` normally only written by Mercurial is not in the
13 13 /// expected format. This indicates a bug in Mercurial, filesystem
14 14 /// corruption, or hardware failure.
15 15 ///
16 16 /// The given string is a short explanation for users, not intended to be
17 17 /// machine-readable.
18 18 CorruptedRepository(String),
19 19
20 20 /// The respository or requested operation involves a feature not
21 21 /// supported by the Rust implementation. Falling back to the Python
22 22 /// implementation may or may not work.
23 23 ///
24 24 /// The given string is a short explanation for users, not intended to be
25 25 /// machine-readable.
26 26 UnsupportedFeature(String),
27 27
28 28 /// Operation cannot proceed for some other reason.
29 29 ///
30 30 /// The given string is a short explanation for users, not intended to be
31 31 /// machine-readable.
32 32 Abort(String),
33 33
34 34 /// A configuration value is not in the expected syntax.
35 35 ///
36 36 /// These errors can happen in many places in the code because values are
37 37 /// parsed lazily as the file-level parser does not know the expected type
38 38 /// and syntax of each value.
39 39 #[from]
40 40 ConfigValueParseError(ConfigValueParseError),
41 41 }
42 42
43 43 /// Details about where an I/O error happened
44 44 #[derive(Debug)]
45 45 pub enum IoErrorContext {
46 46 ReadingFile(std::path::PathBuf),
47 47 WritingFile(std::path::PathBuf),
48 48 RemovingFile(std::path::PathBuf),
49 49 RenamingFile {
50 50 from: std::path::PathBuf,
51 51 to: std::path::PathBuf,
52 52 },
53 /// `std::fs::canonicalize`
54 CanonicalizingPath(std::path::PathBuf),
53 55 /// `std::env::current_dir`
54 56 CurrentDir,
55 57 /// `std::env::current_exe`
56 58 CurrentExe,
57 59 }
58 60
59 61 impl HgError {
60 62 pub fn corrupted(explanation: impl Into<String>) -> Self {
61 63 // TODO: capture a backtrace here and keep it in the error value
62 64 // to aid debugging?
63 65 // https://doc.rust-lang.org/std/backtrace/struct.Backtrace.html
64 66 HgError::CorruptedRepository(explanation.into())
65 67 }
66 68
67 69 pub fn unsupported(explanation: impl Into<String>) -> Self {
68 70 HgError::UnsupportedFeature(explanation.into())
69 71 }
70 72 pub fn abort(explanation: impl Into<String>) -> Self {
71 73 HgError::Abort(explanation.into())
72 74 }
73 75 }
74 76
75 77 // TODO: use `DisplayBytes` instead to show non-Unicode filenames losslessly?
76 78 impl fmt::Display for HgError {
77 79 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
78 80 match self {
79 81 HgError::Abort(explanation) => write!(f, "{}", explanation),
80 82 HgError::IoError { error, context } => {
81 83 write!(f, "abort: {}: {}", context, error)
82 84 }
83 85 HgError::CorruptedRepository(explanation) => {
84 86 write!(f, "abort: {}", explanation)
85 87 }
86 88 HgError::UnsupportedFeature(explanation) => {
87 89 write!(f, "unsupported feature: {}", explanation)
88 90 }
89 91 HgError::ConfigValueParseError(ConfigValueParseError {
90 92 origin: _,
91 93 line: _,
92 94 section,
93 95 item,
94 96 value,
95 97 expected_type,
96 98 }) => {
97 99 // TODO: add origin and line number information, here and in
98 100 // corresponding python code
99 101 write!(
100 102 f,
101 103 "config error: {}.{} is not a {} ('{}')",
102 104 String::from_utf8_lossy(section),
103 105 String::from_utf8_lossy(item),
104 106 expected_type,
105 107 String::from_utf8_lossy(value)
106 108 )
107 109 }
108 110 }
109 111 }
110 112 }
111 113
112 114 // TODO: use `DisplayBytes` instead to show non-Unicode filenames losslessly?
113 115 impl fmt::Display for IoErrorContext {
114 116 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
115 117 match self {
116 118 IoErrorContext::ReadingFile(path) => {
117 119 write!(f, "when reading {}", path.display())
118 120 }
119 121 IoErrorContext::WritingFile(path) => {
120 122 write!(f, "when writing {}", path.display())
121 123 }
122 124 IoErrorContext::RemovingFile(path) => {
123 125 write!(f, "when removing {}", path.display())
124 126 }
125 127 IoErrorContext::RenamingFile { from, to } => write!(
126 128 f,
127 129 "when renaming {} to {}",
128 130 from.display(),
129 131 to.display()
130 132 ),
133 IoErrorContext::CanonicalizingPath(path) => {
134 write!(f, "when canonicalizing {}", path.display())
135 }
131 136 IoErrorContext::CurrentDir => {
132 137 write!(f, "error getting current working directory")
133 138 }
134 139 IoErrorContext::CurrentExe => {
135 140 write!(f, "error getting current executable")
136 141 }
137 142 }
138 143 }
139 144 }
140 145
141 146 pub trait IoResultExt<T> {
142 147 /// Annotate a possible I/O error as related to a reading a file at the
143 148 /// given path.
144 149 ///
145 150 /// This allows printing something like “File not found when reading
146 151 /// example.txt” instead of just “File not found”.
147 152 ///
148 153 /// Converts a `Result` with `std::io::Error` into one with `HgError`.
149 154 fn when_reading_file(self, path: &std::path::Path) -> Result<T, HgError>;
150 155
151 156 fn with_context(
152 157 self,
153 158 context: impl FnOnce() -> IoErrorContext,
154 159 ) -> Result<T, HgError>;
155 160 }
156 161
157 162 impl<T> IoResultExt<T> for std::io::Result<T> {
158 163 fn when_reading_file(self, path: &std::path::Path) -> Result<T, HgError> {
159 164 self.with_context(|| IoErrorContext::ReadingFile(path.to_owned()))
160 165 }
161 166
162 167 fn with_context(
163 168 self,
164 169 context: impl FnOnce() -> IoErrorContext,
165 170 ) -> Result<T, HgError> {
166 171 self.map_err(|error| HgError::IoError {
167 172 error,
168 173 context: context(),
169 174 })
170 175 }
171 176 }
172 177
173 178 pub trait HgResultExt<T> {
174 179 /// Handle missing files separately from other I/O error cases.
175 180 ///
176 181 /// Wraps the `Ok` type in an `Option`:
177 182 ///
178 183 /// * `Ok(x)` becomes `Ok(Some(x))`
179 184 /// * An I/O "not found" error becomes `Ok(None)`
180 185 /// * Other errors are unchanged
181 186 fn io_not_found_as_none(self) -> Result<Option<T>, HgError>;
182 187 }
183 188
184 189 impl<T> HgResultExt<T> for Result<T, HgError> {
185 190 fn io_not_found_as_none(self) -> Result<Option<T>, HgError> {
186 191 match self {
187 192 Ok(x) => Ok(Some(x)),
188 193 Err(HgError::IoError { error, .. })
189 194 if error.kind() == std::io::ErrorKind::NotFound =>
190 195 {
191 196 Ok(None)
192 197 }
193 198 Err(other_error) => Err(other_error),
194 199 }
195 200 }
196 201 }
@@ -1,268 +1,265 b''
1 1 use crate::config::{Config, ConfigError, ConfigParseError};
2 2 use crate::errors::{HgError, IoErrorContext, IoResultExt};
3 3 use crate::requirements;
4 4 use crate::utils::files::get_path_from_bytes;
5 use crate::utils::{current_dir, SliceExt};
5 use crate::utils::SliceExt;
6 6 use memmap::{Mmap, MmapOptions};
7 7 use std::collections::HashSet;
8 8 use std::path::{Path, PathBuf};
9 9
10 10 /// A repository on disk
11 11 pub struct Repo {
12 12 working_directory: PathBuf,
13 13 dot_hg: PathBuf,
14 14 store: PathBuf,
15 15 requirements: HashSet<String>,
16 16 config: Config,
17 17 }
18 18
19 19 #[derive(Debug, derive_more::From)]
20 20 pub enum RepoError {
21 21 NotFound {
22 22 at: PathBuf,
23 23 },
24 24 #[from]
25 25 ConfigParseError(ConfigParseError),
26 26 #[from]
27 27 Other(HgError),
28 28 }
29 29
30 30 impl From<ConfigError> for RepoError {
31 31 fn from(error: ConfigError) -> Self {
32 32 match error {
33 33 ConfigError::Parse(error) => error.into(),
34 34 ConfigError::Other(error) => error.into(),
35 35 }
36 36 }
37 37 }
38 38
39 39 /// Filesystem access abstraction for the contents of a given "base" diretory
40 40 #[derive(Clone, Copy)]
41 41 pub struct Vfs<'a> {
42 42 pub(crate) base: &'a Path,
43 43 }
44 44
45 45 impl Repo {
46 46 /// Find a repository, either at the given path (which must contain a `.hg`
47 47 /// sub-directory) or by searching the current directory and its
48 48 /// ancestors.
49 49 ///
50 50 /// A method with two very different "modes" like this usually a code smell
51 51 /// to make two methods instead, but in this case an `Option` is what rhg
52 52 /// sub-commands get from Clap for the `-R` / `--repository` CLI argument.
53 53 /// Having two methods would just move that `if` to almost all callers.
54 54 pub fn find(
55 55 config: &Config,
56 56 explicit_path: Option<&Path>,
57 57 ) -> Result<Self, RepoError> {
58 58 if let Some(root) = explicit_path {
59 // Having an absolute path isn’t necessary here but can help code
60 // elsewhere
61 let absolute_root = current_dir()?.join(root);
62 if absolute_root.join(".hg").is_dir() {
63 Self::new_at_path(absolute_root, config)
64 } else if absolute_root.is_file() {
59 if root.join(".hg").is_dir() {
60 Self::new_at_path(root.to_owned(), config)
61 } else if root.is_file() {
65 62 Err(HgError::unsupported("bundle repository").into())
66 63 } else {
67 64 Err(RepoError::NotFound {
68 65 at: root.to_owned(),
69 66 })
70 67 }
71 68 } else {
72 69 let current_directory = crate::utils::current_dir()?;
73 70 // ancestors() is inclusive: it first yields `current_directory`
74 71 // as-is.
75 72 for ancestor in current_directory.ancestors() {
76 73 if ancestor.join(".hg").is_dir() {
77 74 return Self::new_at_path(ancestor.to_owned(), config);
78 75 }
79 76 }
80 77 Err(RepoError::NotFound {
81 78 at: current_directory,
82 79 })
83 80 }
84 81 }
85 82
86 83 /// To be called after checking that `.hg` is a sub-directory
87 84 fn new_at_path(
88 85 working_directory: PathBuf,
89 86 config: &Config,
90 87 ) -> Result<Self, RepoError> {
91 88 let dot_hg = working_directory.join(".hg");
92 89
93 90 let mut repo_config_files = Vec::new();
94 91 repo_config_files.push(dot_hg.join("hgrc"));
95 92 repo_config_files.push(dot_hg.join("hgrc-not-shared"));
96 93
97 94 let hg_vfs = Vfs { base: &dot_hg };
98 95 let mut reqs = requirements::load_if_exists(hg_vfs)?;
99 96 let relative =
100 97 reqs.contains(requirements::RELATIVE_SHARED_REQUIREMENT);
101 98 let shared =
102 99 reqs.contains(requirements::SHARED_REQUIREMENT) || relative;
103 100
104 101 // From `mercurial/localrepo.py`:
105 102 //
106 103 // if .hg/requires contains the sharesafe requirement, it means
107 104 // there exists a `.hg/store/requires` too and we should read it
108 105 // NOTE: presence of SHARESAFE_REQUIREMENT imply that store requirement
109 106 // is present. We never write SHARESAFE_REQUIREMENT for a repo if store
110 107 // is not present, refer checkrequirementscompat() for that
111 108 //
112 109 // However, if SHARESAFE_REQUIREMENT is not present, it means that the
113 110 // repository was shared the old way. We check the share source
114 111 // .hg/requires for SHARESAFE_REQUIREMENT to detect whether the
115 112 // current repository needs to be reshared
116 113 let share_safe = reqs.contains(requirements::SHARESAFE_REQUIREMENT);
117 114
118 115 let store_path;
119 116 if !shared {
120 117 store_path = dot_hg.join("store");
121 118 } else {
122 119 let bytes = hg_vfs.read("sharedpath")?;
123 120 let mut shared_path =
124 121 get_path_from_bytes(bytes.trim_end_newlines()).to_owned();
125 122 if relative {
126 123 shared_path = dot_hg.join(shared_path)
127 124 }
128 125 if !shared_path.is_dir() {
129 126 return Err(HgError::corrupted(format!(
130 127 ".hg/sharedpath points to nonexistent directory {}",
131 128 shared_path.display()
132 129 ))
133 130 .into());
134 131 }
135 132
136 133 store_path = shared_path.join("store");
137 134
138 135 let source_is_share_safe =
139 136 requirements::load(Vfs { base: &shared_path })?
140 137 .contains(requirements::SHARESAFE_REQUIREMENT);
141 138
142 139 if share_safe && !source_is_share_safe {
143 140 return Err(match config
144 141 .get(b"share", b"safe-mismatch.source-not-safe")
145 142 {
146 143 Some(b"abort") | None => HgError::abort(
147 144 "abort: share source does not support share-safe requirement\n\
148 145 (see `hg help config.format.use-share-safe` for more information)",
149 146 ),
150 147 _ => HgError::unsupported("share-safe downgrade"),
151 148 }
152 149 .into());
153 150 } else if source_is_share_safe && !share_safe {
154 151 return Err(
155 152 match config.get(b"share", b"safe-mismatch.source-safe") {
156 153 Some(b"abort") | None => HgError::abort(
157 154 "abort: version mismatch: source uses share-safe \
158 155 functionality while the current share does not\n\
159 156 (see `hg help config.format.use-share-safe` for more information)",
160 157 ),
161 158 _ => HgError::unsupported("share-safe upgrade"),
162 159 }
163 160 .into(),
164 161 );
165 162 }
166 163
167 164 if share_safe {
168 165 repo_config_files.insert(0, shared_path.join("hgrc"))
169 166 }
170 167 }
171 168 if share_safe {
172 169 reqs.extend(requirements::load(Vfs { base: &store_path })?);
173 170 }
174 171
175 172 let repo_config = config.combine_with_repo(&repo_config_files)?;
176 173
177 174 let repo = Self {
178 175 requirements: reqs,
179 176 working_directory,
180 177 store: store_path,
181 178 dot_hg,
182 179 config: repo_config,
183 180 };
184 181
185 182 requirements::check(&repo)?;
186 183
187 184 Ok(repo)
188 185 }
189 186
190 187 pub fn working_directory_path(&self) -> &Path {
191 188 &self.working_directory
192 189 }
193 190
194 191 pub fn requirements(&self) -> &HashSet<String> {
195 192 &self.requirements
196 193 }
197 194
198 195 pub fn config(&self) -> &Config {
199 196 &self.config
200 197 }
201 198
202 199 /// For accessing repository files (in `.hg`), except for the store
203 200 /// (`.hg/store`).
204 201 pub fn hg_vfs(&self) -> Vfs<'_> {
205 202 Vfs { base: &self.dot_hg }
206 203 }
207 204
208 205 /// For accessing repository store files (in `.hg/store`)
209 206 pub fn store_vfs(&self) -> Vfs<'_> {
210 207 Vfs { base: &self.store }
211 208 }
212 209
213 210 /// For accessing the working copy
214 211
215 212 // The undescore prefix silences the "never used" warning. Remove before
216 213 // using.
217 214 pub fn _working_directory_vfs(&self) -> Vfs<'_> {
218 215 Vfs {
219 216 base: &self.working_directory,
220 217 }
221 218 }
222 219
223 220 pub fn dirstate_parents(
224 221 &self,
225 222 ) -> Result<crate::dirstate::DirstateParents, HgError> {
226 223 let dirstate = self.hg_vfs().mmap_open("dirstate")?;
227 224 let parents =
228 225 crate::dirstate::parsers::parse_dirstate_parents(&dirstate)?;
229 226 Ok(parents.clone())
230 227 }
231 228 }
232 229
233 230 impl Vfs<'_> {
234 231 pub fn join(&self, relative_path: impl AsRef<Path>) -> PathBuf {
235 232 self.base.join(relative_path)
236 233 }
237 234
238 235 pub fn read(
239 236 &self,
240 237 relative_path: impl AsRef<Path>,
241 238 ) -> Result<Vec<u8>, HgError> {
242 239 let path = self.join(relative_path);
243 240 std::fs::read(&path).when_reading_file(&path)
244 241 }
245 242
246 243 pub fn mmap_open(
247 244 &self,
248 245 relative_path: impl AsRef<Path>,
249 246 ) -> Result<Mmap, HgError> {
250 247 let path = self.base.join(relative_path);
251 248 let file = std::fs::File::open(&path).when_reading_file(&path)?;
252 249 // TODO: what are the safety requirements here?
253 250 let mmap = unsafe { MmapOptions::new().map(&file) }
254 251 .when_reading_file(&path)?;
255 252 Ok(mmap)
256 253 }
257 254
258 255 pub fn rename(
259 256 &self,
260 257 relative_from: impl AsRef<Path>,
261 258 relative_to: impl AsRef<Path>,
262 259 ) -> Result<(), HgError> {
263 260 let from = self.join(relative_from);
264 261 let to = self.join(relative_to);
265 262 std::fs::rename(&from, &to)
266 263 .with_context(|| IoErrorContext::RenamingFile { from, to })
267 264 }
268 265 }
@@ -1,67 +1,69 b''
1 1 use crate::error::CommandError;
2 2 use clap::Arg;
3 3 use hg::operations::cat;
4 4 use hg::utils::hg_path::HgPathBuf;
5 5 use micro_timer::timed;
6 6 use std::convert::TryFrom;
7 7
8 8 pub const HELP_TEXT: &str = "
9 9 Output the current or given revision of files
10 10 ";
11 11
12 12 pub fn args() -> clap::App<'static, 'static> {
13 13 clap::SubCommand::with_name("cat")
14 14 .arg(
15 15 Arg::with_name("rev")
16 16 .help("search the repository as it is in REV")
17 17 .short("-r")
18 18 .long("--revision")
19 19 .value_name("REV")
20 20 .takes_value(true),
21 21 )
22 22 .arg(
23 23 clap::Arg::with_name("files")
24 24 .required(true)
25 25 .multiple(true)
26 26 .empty_values(false)
27 27 .value_name("FILE")
28 28 .help("Activity to start: activity@category"),
29 29 )
30 30 .about(HELP_TEXT)
31 31 }
32 32
33 33 #[timed]
34 34 pub fn run(invocation: &crate::CliInvocation) -> Result<(), CommandError> {
35 35 let rev = invocation.subcommand_args.value_of("rev");
36 36 let file_args = match invocation.subcommand_args.values_of("files") {
37 37 Some(files) => files.collect(),
38 38 None => vec![],
39 39 };
40 40
41 41 let repo = invocation.repo?;
42 42 let cwd = hg::utils::current_dir()?;
43 let working_directory = repo.working_directory_path();
44 let working_directory = cwd.join(working_directory); // Make it absolute
43 45
44 46 let mut files = vec![];
45 47 for file in file_args.iter() {
46 48 // TODO: actually normalize `..` path segments etc?
47 49 let normalized = cwd.join(&file);
48 50 let stripped = normalized
49 .strip_prefix(&repo.working_directory_path())
51 .strip_prefix(&working_directory)
50 52 // TODO: error message for path arguments outside of the repo
51 53 .map_err(|_| CommandError::abort(""))?;
52 54 let hg_file = HgPathBuf::try_from(stripped.to_path_buf())
53 55 .map_err(|e| CommandError::abort(e.to_string()))?;
54 56 files.push(hg_file);
55 57 }
56 58
57 59 match rev {
58 60 Some(rev) => {
59 61 let data = cat(&repo, rev, &files).map_err(|e| (e, rev))?;
60 62 invocation.ui.write_stdout(&data)?;
61 63 Ok(())
62 64 }
63 65 None => Err(CommandError::unsupported(
64 66 "`rhg cat` without `--rev` / `-r`",
65 67 )),
66 68 }
67 69 }
@@ -1,68 +1,71 b''
1 1 use crate::error::CommandError;
2 2 use crate::ui::Ui;
3 3 use clap::Arg;
4 4 use hg::operations::list_rev_tracked_files;
5 5 use hg::operations::Dirstate;
6 6 use hg::repo::Repo;
7 use hg::utils::current_dir;
7 8 use hg::utils::files::{get_bytes_from_path, relativize_path};
8 9 use hg::utils::hg_path::{HgPath, HgPathBuf};
9 10
10 11 pub const HELP_TEXT: &str = "
11 12 List tracked files.
12 13
13 14 Returns 0 on success.
14 15 ";
15 16
16 17 pub fn args() -> clap::App<'static, 'static> {
17 18 clap::SubCommand::with_name("files")
18 19 .arg(
19 20 Arg::with_name("rev")
20 21 .help("search the repository as it is in REV")
21 22 .short("-r")
22 23 .long("--revision")
23 24 .value_name("REV")
24 25 .takes_value(true),
25 26 )
26 27 .about(HELP_TEXT)
27 28 }
28 29
29 30 pub fn run(invocation: &crate::CliInvocation) -> Result<(), CommandError> {
30 31 let relative = invocation.config.get(b"ui", b"relative-paths");
31 32 if relative.is_some() {
32 33 return Err(CommandError::unsupported(
33 34 "non-default ui.relative-paths",
34 35 ));
35 36 }
36 37
37 38 let rev = invocation.subcommand_args.value_of("rev");
38 39
39 40 let repo = invocation.repo?;
40 41 if let Some(rev) = rev {
41 42 let files = list_rev_tracked_files(repo, rev).map_err(|e| (e, rev))?;
42 43 display_files(invocation.ui, repo, files.iter())
43 44 } else {
44 45 let distate = Dirstate::new(repo)?;
45 46 let files = distate.tracked_files()?;
46 47 display_files(invocation.ui, repo, files)
47 48 }
48 49 }
49 50
50 51 fn display_files<'a>(
51 52 ui: &Ui,
52 53 repo: &Repo,
53 54 files: impl IntoIterator<Item = &'a HgPath>,
54 55 ) -> Result<(), CommandError> {
55 56 let cwd = HgPathBuf::from(get_bytes_from_path(hg::utils::current_dir()?));
57 let working_directory = repo.working_directory_path();
58 let working_directory = current_dir()?.join(working_directory); // Make it absolute
56 59 let working_directory =
57 HgPathBuf::from(get_bytes_from_path(repo.working_directory_path()));
60 HgPathBuf::from(get_bytes_from_path(working_directory));
58 61
59 62 let mut stdout = ui.stdout_buffer();
60 63
61 64 for file in files {
62 65 let file = working_directory.join(file);
63 66 stdout.write_all(relativize_path(&file, &cwd).as_ref())?;
64 67 stdout.write_all(b"\n")?;
65 68 }
66 69 stdout.flush()?;
67 70 Ok(())
68 71 }
@@ -1,22 +1,28 b''
1 1 use crate::error::CommandError;
2 2 use format_bytes::format_bytes;
3 use hg::errors::{IoErrorContext, IoResultExt};
3 4 use hg::utils::files::get_bytes_from_path;
4 5
5 6 pub const HELP_TEXT: &str = "
6 7 Print the root directory of the current repository.
7 8
8 9 Returns 0 on success.
9 10 ";
10 11
11 12 pub fn args() -> clap::App<'static, 'static> {
12 13 clap::SubCommand::with_name("root").about(HELP_TEXT)
13 14 }
14 15
15 16 pub fn run(invocation: &crate::CliInvocation) -> Result<(), CommandError> {
16 17 let repo = invocation.repo?;
17 let bytes = get_bytes_from_path(repo.working_directory_path());
18 let working_directory = repo.working_directory_path();
19 let working_directory = std::fs::canonicalize(working_directory)
20 .with_context(|| {
21 IoErrorContext::CanonicalizingPath(working_directory.to_owned())
22 })?;
23 let bytes = get_bytes_from_path(&working_directory);
18 24 invocation
19 25 .ui
20 26 .write_stdout(&format_bytes!(b"{}\n", bytes.as_slice()))?;
21 27 Ok(())
22 28 }
General Comments 0
You need to be logged in to leave comments. Login now