diff --git a/applyr/config.py b/applyr/config.py index e776829..c23b103 100644 --- a/applyr/config.py +++ b/applyr/config.py @@ -8,6 +8,11 @@ APPLYR_DIR = Path(os.environ.get("APPLYR_HOME", Path.home() / ".applyr")) +# Generated CVs are deliverables the user has to find and attach elsewhere +# (a form, an email, LinkedIn) — unlike APPLYR_DIR's config/db/cv-master.md, +# they default outside the dotfile so a normal file browser shows them. +CV_HOME = Path(os.environ.get("APPLYR_CV_HOME", Path.home() / "Documents" / "applyr")) + # Topic display names — hardcoded, not configurable TOPIC_LABELS = { "tech_stack": "Tech Stack", @@ -40,7 +45,9 @@ [cv] # cv_master — the profile applyr reads to fill generated CVs. Fill it before # running 'cv generate'; a stub file produces an empty CV. -# output_dir — where generated CVs are written. +# output_dir — where generated CVs are written. Defaults outside the applyr +# config directory so they show up in a file browser without +# unhiding dotfiles. # Both accept any path, e.g. a private repo you already version. If you point # them at a repo, make sure it is gitignored — CVs contain personal data. # language — the language generated CVs are written in when an offer does not @@ -48,7 +55,7 @@ # in another language overrides it via "language" in 'applyr add'. # Supported: en, es. cv_master = "__APPLYR_DIR__/cv-master.md" -output_dir = "__APPLYR_DIR__/cv" +output_dir = "__CV_HOME__/cv" language = "en" """ @@ -105,7 +112,7 @@ def _build_defaults() -> dict: "cv": { "chrome_path": _detect_chrome(), "cv_master": str(APPLYR_DIR / "cv-master.md"), - "output_dir": str(APPLYR_DIR / "cv"), + "output_dir": str(CV_HOME / "cv"), "language": "en", }, } @@ -148,10 +155,13 @@ def create_default_config(): config_path = APPLYR_DIR / "applyr.toml" if not config_path.exists(): - # The template ships a placeholder rather than a literal ~/.applyr so - # that an APPLYR_HOME install does not write paths pointing outside it. - config_path.write_text(TOML_TEMPLATE.replace("__APPLYR_DIR__", str(APPLYR_DIR))) + # The template ships placeholders rather than literal paths so that an + # APPLYR_HOME/APPLYR_CV_HOME install does not write paths pointing + # outside them. + config_path.write_text( + TOML_TEMPLATE.replace("__APPLYR_DIR__", str(APPLYR_DIR)).replace("__CV_HOME__", str(CV_HOME)) + ) print(f" Created {config_path}") - cv_dir = APPLYR_DIR / "cv" - cv_dir.mkdir(exist_ok=True) + cv_dir = CV_HOME / "cv" + cv_dir.mkdir(parents=True, exist_ok=True) diff --git a/applyr/cv.py b/applyr/cv.py index f9c186a..1609845 100644 --- a/applyr/cv.py +++ b/applyr/cv.py @@ -748,7 +748,7 @@ def cmd_cv_review_blind(offer_id: int, as_json: bool = False) -> None: conn.close() # 2. Read cv-master.md fresh - cv_master_path = APPLYR_DIR / "cv-master.md" + cv_master_path = get_cv_master_path() if not cv_master_path.exists(): die("Error: cv-master.md not found. Run 'applyr init' first.", code="cv_master_missing") @@ -927,7 +927,7 @@ def cmd_cv_keywords(offer_id: int, as_json: bool = False) -> None: # the command told users to run the generate step they had just run. # `cv_used` is the authoritative link, so read it first and keep the glob # as a fallback for hand-named files. - cv_dir = APPLYR_DIR / "cv" + cv_dir = get_output_dir() cv_path = None recorded = offer_data.get("cv_used") @@ -1249,7 +1249,7 @@ def cmd_cv_cover_letter(offer_id: int, as_json: bool = False) -> None: offer_data = dict(offer) # Load cv-master - cv_master_path = APPLYR_DIR / "cv-master.md" + cv_master_path = get_cv_master_path() if not cv_master_path.exists(): die("cv-master.md not found. Run 'applyr init' first.", code="no_cv_master") @@ -1303,8 +1303,7 @@ def cmd_cv_cover_letter(offer_id: int, as_json: bool = False) -> None: # Save to file company_slug = offer_data.get("company", "unknown").lower().replace(" ", "-") - output_path = APPLYR_DIR / "cv" / f"cover-letter-{company_slug}.txt" - output_path.parent.mkdir(parents=True, exist_ok=True) + output_path = get_output_dir() / f"cover-letter-{company_slug}.txt" output_path.write_text(letter) if as_json: diff --git a/tests/conftest.py b/tests/conftest.py index cbbdc1d..14daaf4 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -18,6 +18,9 @@ def tmp_applyr(tmp_path, monkeypatch): # Force config module to use the tmp path import applyr.config as cfg monkeypatch.setattr(cfg, "APPLYR_DIR", applyr_home) + # CV_HOME defaults outside APPLYR_DIR in production; tests keep it inside + # the same sandbox so nothing escapes tmp_path. + monkeypatch.setattr(cfg, "CV_HOME", applyr_home) return applyr_home diff --git a/tests/test_config.py b/tests/test_config.py index b8f5b8c..f1f76ef 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -104,3 +104,31 @@ def test_does_not_overwrite(self, tmp_applyr, capsys): (tmp_applyr / "applyr.toml").write_text("custom") create_default_config() assert (tmp_applyr / "applyr.toml").read_text() == "custom" + + +@pytest.mark.unit +class TestCvOutputDirLocation: + """Generated CVs are a deliverable the user has to find and attach + elsewhere, unlike the config/db/cv-master.md — they must default outside + the applyr config directory so a normal file browser shows them.""" + + def test_default_output_dir_follows_cv_home_not_applyr_dir(self, tmp_applyr, monkeypatch, tmp_path): + import applyr.config as cfg + + other_dir = tmp_path / "elsewhere" + monkeypatch.setattr(cfg, "CV_HOME", other_dir) + + config = load_config() + assert config["cv"]["output_dir"] == str(other_dir / "cv") + assert not config["cv"]["output_dir"].startswith(str(tmp_applyr)) + + def test_written_toml_points_at_cv_home(self, tmp_applyr, monkeypatch, tmp_path): + import applyr.config as cfg + + other_dir = tmp_path / "elsewhere" + monkeypatch.setattr(cfg, "CV_HOME", other_dir) + + create_default_config() + toml_text = (tmp_applyr / "applyr.toml").read_text() + assert str(other_dir / "cv") in toml_text + assert (other_dir / "cv").is_dir() diff --git a/tests/test_cv.py b/tests/test_cv.py index 9f8ea72..9e97e5c 100644 --- a/tests/test_cv.py +++ b/tests/test_cv.py @@ -165,6 +165,23 @@ def test_existing_offer_proceeds_past_null_check(self, offer_id, capsys): assert "not found" not in err assert "No CV found" in err + def test_finds_cv_when_output_dir_differs_from_applyr_dir(self, offer_id, tmp_applyr, monkeypatch, tmp_path): + """The lookup hardcoded APPLYR_DIR / "cv" instead of reading the + configured output_dir, so it stopped finding CVs the moment CV_HOME + diverged from APPLYR_DIR — exactly the setup generated CVs now default + to (they live outside the config directory).""" + import applyr.config as cfg + from applyr.cv import cmd_cv_generate, cmd_cv_keywords + + other_dir = tmp_path / "elsewhere" + monkeypatch.setattr(cfg, "CV_HOME", other_dir) + + cmd_cv_generate(offer_id) + assert not (tmp_applyr / "cv").exists() + assert (other_dir / "cv").exists() + + cmd_cv_keywords(offer_id) # must not raise "No CV found" + class TestKeywordMatchingIgnoresFrontmatter: """`cv keywords` matched the offer's keywords against the whole generated @@ -218,3 +235,29 @@ def test_unterminated_frontmatter_is_left_alone(self): broken = "---\noffer_id: 1\n\n# Jane Doe\n" assert "# Jane Doe" in _strip_frontmatter(broken) + + +class TestCvCoverLetter: + """The cover letter writer hardcoded APPLYR_DIR / "cv" too, so it wrote + outside the folder `applyr cv keywords` and the user were both looking in + the moment output_dir diverged from APPLYR_DIR.""" + + def test_writes_to_configured_output_dir(self, offer_id, tmp_applyr, monkeypatch, tmp_path): + import applyr.config as cfg + from applyr.db import get_conn + from applyr.cv import cmd_cv_cover_letter + + # generate_cover_letter() needs a non-null tech_stack; the shared + # `offer_id` fixture does not set one. + conn = get_conn() + conn.execute("UPDATE offers SET tech_stack = ? WHERE id = ?", ("Python", offer_id)) + conn.commit() + conn.close() + + other_dir = tmp_path / "elsewhere" + monkeypatch.setattr(cfg, "CV_HOME", other_dir) + + cmd_cv_cover_letter(offer_id) + + assert list((other_dir / "cv").glob("cover-letter-*.txt")) + assert not (tmp_applyr / "cv").exists()