Add TestProject wrapper for mutable tests.

Change-Id: I1e03bc8fbb78411375777388620f522cde12b21f
Reviewed-on: https://pigweed-review.googlesource.com/c/pigweed/qg/+/126771
Commit-Queue: Erik Gilling <konkers@google.com>
Reviewed-by: Alexei Frolov <frolv@google.com>
diff --git a/qg/Cargo.toml b/qg/Cargo.toml
index f242e21..7e12ce2 100644
--- a/qg/Cargo.toml
+++ b/qg/Cargo.toml
@@ -12,16 +12,17 @@
 fixedbitset = "0.4.2"
 futures = "0.3.25"
 num-traits = "0.2.15"
+nom = "7.1.2"
 once_cell = "1.16.0"
 petgraph = "0.6.2"
 regex = "1.7.0"
+reqwest = "0.11.13"
 serde = { version = "1.0.147", features = ["derive"] }
+sha2 = "0.10.6"
 thiserror = "1.0.37"
 tokio = { version = "1.21.2", features = ["full"] }
 toml = "0.5.9"
-nom = "7.1.2"
-reqwest = "0.11.13"
-sha2 = "0.10.6"
+walkdir = "2"
 
 [dependencies.rustpython]
 git = "https://github.com/RustPython/RustPython"
diff --git a/qg/src/lib.rs b/qg/src/lib.rs
index 4ac1154..c1a412e 100644
--- a/qg/src/lib.rs
+++ b/qg/src/lib.rs
@@ -31,6 +31,9 @@
 #[cfg(test)]
 pub(crate) mod fake;
 
+#[cfg(test)]
+pub(crate) mod test_util;
+
 #[doc(inline)]
 pub use target::Target;
 
diff --git a/qg/src/test_util.rs b/qg/src/test_util.rs
new file mode 100644
index 0000000..e4db038
--- /dev/null
+++ b/qg/src/test_util.rs
@@ -0,0 +1,65 @@
+// Copyright 2023 The Pigweed Authors
+//
+// Licensed under the Apache License, Version 2.0 (the "License"); you may not
+// use this file except in compliance with the License. You may obtain a copy of
+// the License at
+//
+//     https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+// License for the specific language governing permissions and limitations under
+// the License.
+
+use std::path::{Path, PathBuf};
+
+use tempfile::{Builder, TempDir};
+
+use crate::{util::copy_directory, Project, Result};
+
+/// `TestProject` copies a project to a temporary directory for tests
+/// which need to write to the project directory.
+///
+/// The temporary directory is deleted when the `TestProject` is dropped.
+pub(crate) struct TestProject {
+    pub project: Project,
+
+    // `TempDir` will be deleted when the `TestProject` is dropped.
+    project_root: TempDir,
+}
+
+impl TestProject {
+    /// Creates a new `TestProject` from the project at `path`
+    ///
+    /// # Errors
+    /// Returns an error if they project can not be copied.
+    pub fn new(path: impl AsRef<Path>) -> Result<Self> {
+        let test_project_root = Builder::new().prefix("qg-test-project").tempdir()?;
+
+        copy_directory(path, &test_project_root)?;
+
+        Ok(Self {
+            project: Project::load(&test_project_root)?,
+            project_root: test_project_root,
+        })
+    }
+
+    /// Returns a `PathBuf` to the file at the relative path `path` within
+    /// the `TestProject` temporary copy.
+    #[allow(unused)]
+    pub fn relative_path(&self, path: impl AsRef<Path>) -> PathBuf {
+        self.project_root.path().join(path)
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn test_project() {
+        let test_project = TestProject::new("./src/test_projects/simple").unwrap();
+        assert_eq!(test_project.project.name(), "test-qg-project");
+    }
+}
diff --git a/qg/src/util.rs b/qg/src/util.rs
index af907fb..6bd1320 100644
--- a/qg/src/util.rs
+++ b/qg/src/util.rs
@@ -26,8 +26,54 @@
 use once_cell::sync::Lazy;
 use regex::Regex;
 
+#[cfg(test)]
+use std::path::Path;
+#[cfg(test)]
+use walkdir::WalkDir;
+
 use crate::{Error, Result};
 
+/// Copy `src` directory to `dest`.  `dest` will be created if it or its
+/// parents do not exist.
+///
+/// # Errors
+/// Returns errors when:
+/// - The `src` directory does not exist.
+/// - A directory or file can not be created in the `dest` directory.
+/// - The `src` directory contains symlinks.
+#[cfg(test)] // Only used in tests.  Remove if used elsewhere
+pub(crate) fn copy_directory(src: impl AsRef<Path>, dest: impl AsRef<Path>) -> Result<()> {
+    copy_directory_impl(src.as_ref(), dest.as_ref())
+}
+
+#[cfg(test)] // Only used in tests.  Remove if used elsewhere
+pub(crate) fn copy_directory_impl(src: &Path, dest: &Path) -> Result<()> {
+    let dest = dest.to_path_buf();
+    for entry in WalkDir::new(src) {
+        let entry = entry.map_err(|e| Error::StringErrorPlaceholder(e.to_string()))?;
+
+        let src_relative_path = entry
+            .path()
+            .strip_prefix(&src)
+            .expect("child entry should be in directory path");
+        let dest_location = dest.join(&src_relative_path);
+
+        if entry.file_type().is_dir() {
+            std::fs::create_dir_all(dest_location)?;
+        } else if entry.file_type().is_file() {
+            // Because we're walking the directory, a file's parent directory
+            // will already be created.
+            std::fs::copy(entry.path(), dest_location)?;
+        } else if entry.file_type().is_symlink() {
+            return Err(Error::StringErrorPlaceholder(format!(
+                "copying symlinks not supported: {entry:?}"
+            )));
+        }
+    }
+
+    Ok(())
+}
+
 #[derive(Debug)]
 enum StringFragment {
     Literal(String),