From 7b383d31abbb3069b80806fbc73fa7bb39684ebf Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Mon, 11 May 2026 09:15:48 +0200 Subject: [PATCH] fix: decompose monolithic functions into focused helpers (CSM-023) Break up SRP violations in Templates.do_update_template/2 and Capabilities.for_project/2 by extracting domain-specific builder functions and single-responsibility private helpers. Co-Authored-By: Claude Opus 4.6 --- CODESMELL.md | 19 +- lib/bds/scripting/capabilities.ex | 498 +++++++++++++----------- lib/bds/templates.ex | 110 +++--- test/bds/csm023_srp_violations_test.exs | 85 ++++ 4 files changed, 432 insertions(+), 280 deletions(-) create mode 100644 test/bds/csm023_srp_violations_test.exs diff --git a/CODESMELL.md b/CODESMELL.md index 4b6bf0e..70967b3 100644 --- a/CODESMELL.md +++ b/CODESMELL.md @@ -369,11 +369,20 @@ --- -### CSM-023 — SRP Violations -- **Files:** - - `lib/bds/templates.ex:86-163` — `update_template/2` does slug changes, content changes, status transitions, file paths, transactions, cascades, and filesystem sync. - - `lib/bds/scripting/capabilities.ex:22-248` — `for_project/2` returns a 200+ line map literal. -- **Fix:** Decompose into smaller private pipelines or domain-specific builder functions. +### ~~CSM-023 — SRP Violations~~ ✅ FIXED +- **Fixed:** 2026-05-11 +- **What was done:** + - **`lib/bds/templates.ex`** — `do_update_template/2`: + - Extracted `resolve_next_slug/2` — determines slug from attrs or keeps current. + - Extracted `content_changed?/2` — checks if content attr differs from effective content. + - Extracted `resolve_next_status/2` — pattern-matched function heads for status transition (published + content change → draft). + - Extracted `build_update_attrs/5` — assembles the changeset map from resolved values. + - Extracted `commit_update_transaction/4` — runs the Repo transaction with cascade logic. + - `do_update_template/2` is now a concise pipeline: resolve → build → commit → sync. + - **`lib/bds/scripting/capabilities.ex`** — `for_project/2`: + - Extracted 13 domain-specific builder functions: `app_capabilities/2`, `project_capabilities/1`, `meta_capabilities/1`, `post_capabilities/1`, `media_capabilities/1`, `script_capabilities/1`, `template_capabilities/1`, `tag_capabilities/1`, `task_capabilities/0`, `sync_capabilities/2`, `publish_capabilities/2`, `chat_capabilities/1`, `embedding_capabilities/1`. + - `for_project/2` is now a 15-line dispatch map. + - Added 5 tests in `test/bds/csm023_srp_violations_test.exs`: source-level assertions for helper extraction in templates, delegation in do_update_template, builder function presence in capabilities, concise for_project body (≤20 lines), no inline capability definitions in for_project. --- diff --git a/lib/bds/scripting/capabilities.ex b/lib/bds/scripting/capabilities.ex index 11abd01..38756e8 100644 --- a/lib/bds/scripting/capabilities.ex +++ b/lib/bds/scripting/capabilities.ex @@ -25,229 +25,281 @@ defmodule BDS.Scripting.Capabilities do @spec for_project(String.t(), keyword()) :: map() def for_project(project_id, opts \\ []) when is_binary(project_id) and is_list(opts) do %{ - app: %{ - copy_to_clipboard: one_arg(fn text -> copy_to_clipboard(text, opts) end), - get_data_paths: zero_or_one_arg(fn _args -> data_paths(project_id) end), - get_blogmark_bookmarklet: zero_or_one_arg(fn _args -> blogmark_bookmarklet() end), - get_system_language: zero_or_one_arg(fn _args -> I18n.current_ui_locale() end), - get_default_project_path: zero_or_one_arg(fn _args -> project_path(project_id) end), - get_title_bar_metrics: zero_or_one_arg(fn _args -> title_bar_metrics(opts) end), - notify_renderer_ready: zero_or_one_arg(fn _args -> notify_renderer_ready(opts) end), - open_folder: one_arg(fn folder_path -> open_folder(folder_path, opts) end), - read_project_metadata: one_arg(fn folder_path -> read_project_metadata(folder_path) end), - select_folder: one_arg(fn title -> select_folder(title, opts) end), - set_preview_post_target: one_arg(fn post_id -> set_preview_post_target(post_id) end), - show_item_in_folder: one_arg(fn item_path -> show_item_in_folder(item_path, opts) end), - trigger_menu_action: one_arg(fn action -> trigger_menu_action(action, opts) end) - }, - projects: %{ - create: zero_or_one_arg(fn attrs -> create_project(attrs) end), - delete: one_arg(fn project_id_to_delete -> delete_project(project_id_to_delete) end), - delete_with_data: - one_arg(fn project_id_to_delete -> delete_project_with_data(project_id_to_delete) end), - get: one_arg(fn project_id_to_load -> load_project(project_id_to_load) end), - get_all: zero_or_one_arg(fn _args -> list_projects() end), - get_active: zero_or_one_arg(fn _args -> load_project(project_id) end), - set_active: - one_arg(fn project_id_to_activate -> set_active_project(project_id_to_activate) end), - update: - two_arg(fn project_id_to_update, attrs -> - update_project(project_id_to_update, attrs) - end) - }, - meta: %{ - get_project_metadata: zero_or_one_arg(fn _args -> load_metadata(project_id) end), - update_project_metadata: - one_arg(fn attrs -> update_project_metadata(project_id, attrs) end), - add_category: one_arg(fn name -> add_category(project_id, name) end), - remove_category: one_arg(fn name -> remove_category(project_id, name) end), - add_tag: one_arg(fn name -> add_meta_tag(project_id, name) end), - get_categories: zero_or_one_arg(fn _args -> metadata_categories(project_id) end), - set_project_metadata: one_arg(fn attrs -> update_project_metadata(project_id, attrs) end), - get_publishing_preferences: - zero_or_one_arg(fn _args -> publishing_preferences(project_id) end), - get_tags: zero_or_one_arg(fn _args -> metadata_tags(project_id) end), - remove_tag: one_arg(fn name -> remove_meta_tag(project_id, name) end), - set_publishing_preferences: - one_arg(fn prefs -> set_publishing_preferences(project_id, prefs) end), - clear_publishing_preferences: - zero_or_one_arg(fn _args -> clear_publishing_preferences(project_id) end), - sync_on_startup: zero_or_one_arg(fn _args -> sync_meta_on_startup(project_id) end) - }, - posts: %{ - create: one_arg(fn attrs -> create_post(project_id, attrs) end), - discard: one_arg(fn post_id -> discard_post(project_id, post_id) end), - filter: one_arg(fn filters -> filter_posts(project_id, filters) end), - generate_unique_slug: - two_arg(fn title, exclude_post_id -> - generate_unique_post_slug(project_id, title, exclude_post_id) - end), - get_by_status: one_arg(fn status -> posts_by_status(project_id, status) end), - get_by_year_month: zero_or_one_arg(fn _args -> post_counts_by_year_month(project_id) end), - get_dashboard_stats: zero_or_one_arg(fn _args -> post_dashboard_stats(project_id) end), - get_linked_by: - one_arg(fn post_id -> linked_posts_for(project_id, post_id, :incoming) end), - get_links_to: one_arg(fn post_id -> linked_posts_for(project_id, post_id, :outgoing) end), - get_preview_url: - two_arg(fn post_id, options -> preview_url(project_id, post_id, options) end), - update: two_arg(fn post_id, attrs -> update_post(project_id, post_id, attrs) end), - delete: one_arg(fn post_id -> delete_post(project_id, post_id) end), - get: one_arg(fn post_id -> load_post(project_id, post_id) end), - get_all: zero_or_one_arg(fn _args -> list_posts(project_id) end), - get_by_slug: one_arg(fn slug -> load_post_by_slug(project_id, slug) end), - get_categories: zero_or_one_arg(fn _args -> post_categories(project_id) end), - get_categories_with_counts: - zero_or_one_arg(fn _args -> post_categories_with_counts(project_id) end), - get_tags: zero_or_one_arg(fn _args -> post_tags(project_id) end), - get_tags_with_counts: zero_or_one_arg(fn _args -> post_tags_with_counts(project_id) end), - get_translation: - two_arg(fn post_id, language -> - load_post_translation(project_id, post_id, language) - end), - get_translations: one_arg(fn post_id -> list_post_translations(project_id, post_id) end), - has_published_version: - one_arg(fn post_id -> has_published_post_version(project_id, post_id) end), - is_slug_available: - two_arg(fn slug, exclude_post_id -> - post_slug_available?(project_id, slug, exclude_post_id) - end), - publish: one_arg(fn post_id -> publish_post(project_id, post_id) end), - publish_translation: - two_arg(fn post_id, language -> - publish_post_translation(project_id, post_id, language) - end), - rebuild_from_files: zero_or_one_arg(fn _args -> rebuild_posts_from_files(project_id) end), - rebuild_links: zero_or_one_arg(fn _args -> rebuild_post_links(project_id) end), - reindex_text: zero_or_one_arg(fn _args -> reindex_project_search(project_id) end), - search: one_arg(fn query -> search_posts(project_id, query) end) - }, - media: %{ - delete_translation: - two_arg(fn media_id, language -> - delete_media_translation(project_id, media_id, language) - end), - filter: one_arg(fn filters -> filter_media(project_id, filters) end), - import: one_arg(fn attrs -> import_media(project_id, attrs) end), - get_by_year_month: - zero_or_one_arg(fn _args -> media_counts_by_year_month(project_id) end), - get_file_path: one_arg(fn media_id -> media_file_path(project_id, media_id) end), - update: two_arg(fn media_id, attrs -> update_media(project_id, media_id, attrs) end), - delete: one_arg(fn media_id -> delete_media(project_id, media_id) end), - get: one_arg(fn media_id -> load_media(project_id, media_id) end), - get_all: zero_or_one_arg(fn _args -> list_media(project_id) end), - get_tags: zero_or_one_arg(fn _args -> media_tags(project_id) end), - get_tags_with_counts: zero_or_one_arg(fn _args -> media_tags_with_counts(project_id) end), - get_thumbnail: - two_arg(fn media_id, size -> media_thumbnail(project_id, media_id, size) end), - get_translation: - two_arg(fn media_id, language -> - load_media_translation(project_id, media_id, language) - end), - get_translations: - one_arg(fn media_id -> list_media_translations(project_id, media_id) end), - get_url: one_arg(fn media_id -> media_url(project_id, media_id) end), - rebuild_from_files: zero_or_one_arg(fn _args -> rebuild_media_from_files(project_id) end), - regenerate_missing_thumbnails: - zero_or_one_arg(fn _args -> regenerate_missing_thumbnails(project_id) end), - regenerate_thumbnails: - one_arg(fn media_id -> regenerate_media_thumbnails(project_id, media_id) end), - reindex_text: zero_or_one_arg(fn _args -> reindex_project_search(project_id) end), - replace_file: - two_arg(fn media_id, source_path -> - replace_media_file(project_id, media_id, source_path) - end), - search: one_arg(fn query -> search_media(project_id, query) end), - upsert_translation: - three_arg(fn media_id, language, attrs -> - upsert_media_translation(project_id, media_id, language, attrs) - end) - }, - scripts: %{ - create: one_arg(fn attrs -> create_script(project_id, attrs) end), - update: two_arg(fn script_id, attrs -> update_script(project_id, script_id, attrs) end), - delete: one_arg(fn script_id -> delete_script(project_id, script_id) end), - get: one_arg(fn script_id -> load_script(project_id, script_id) end), - get_all: zero_or_one_arg(fn _args -> list_scripts(project_id) end), - publish: one_arg(fn script_id -> publish_script(project_id, script_id) end), - rebuild_from_files: - zero_or_one_arg(fn _args -> rebuild_scripts_from_files(project_id) end) - }, - templates: %{ - create: one_arg(fn attrs -> create_template(project_id, attrs) end), - update: - two_arg(fn template_id, attrs -> update_template(project_id, template_id, attrs) end), - delete: one_arg(fn template_id -> delete_template(project_id, template_id) end), - get: one_arg(fn template_id -> load_template(project_id, template_id) end), - get_all: zero_or_one_arg(fn _args -> list_templates(project_id) end), - publish: one_arg(fn template_id -> publish_template(project_id, template_id) end), - get_enabled_by_kind: one_arg(fn kind -> list_enabled_templates(project_id, kind) end), - rebuild_from_files: - zero_or_one_arg(fn _args -> rebuild_templates_from_files(project_id) end), - validate: one_arg(fn source -> validate_template_source(source) end) - }, - tags: %{ - create: one_arg(fn attrs -> create_tag(project_id, attrs) end), - update: two_arg(fn tag_id, attrs -> update_tag(project_id, tag_id, attrs) end), - delete: one_arg(fn tag_id -> delete_tag(project_id, tag_id) end), - get: one_arg(fn tag_id -> load_tag(project_id, tag_id) end), - get_all: zero_or_one_arg(fn _args -> list_tags(project_id) end), - get_by_name: one_arg(fn tag_name -> load_tag_by_name(project_id, tag_name) end), - get_posts_with_tag: one_arg(fn tag_id -> tag_post_ids(project_id, tag_id) end), - get_with_counts: zero_or_one_arg(fn _args -> tags_with_counts(project_id) end), - merge: - two_arg(fn source_tag_ids, target_tag_id -> - merge_tags(project_id, source_tag_ids, target_tag_id) - end), - rename: two_arg(fn tag_id, new_name -> rename_tag(project_id, tag_id, new_name) end), - sync_from_posts: zero_or_one_arg(fn _args -> sync_tags_from_posts(project_id) end) - }, - tasks: %{ - get: one_arg(fn task_id -> load_task(task_id) end), - status_snapshot: zero_or_one_arg(fn _args -> sanitize(Tasks.status_snapshot()) end), - cancel: one_arg(fn task_id -> cancel_task(task_id) end), - get_all: zero_or_one_arg(fn _args -> list_all_tasks() end), - get_running: zero_or_one_arg(fn _args -> list_running_tasks() end), - clear_completed: zero_or_one_arg(fn _args -> clear_completed_tasks() end) - }, - sync: %{ - check_availability: zero_or_one_arg(fn _args -> sync_available?() end), - get_repo_state: zero_or_one_arg(fn _args -> repo_state(project_id, opts) end), - get_status: zero_or_one_arg(fn _args -> repo_status(project_id, opts) end), - get_history: zero_or_one_arg(fn _args -> repo_history(project_id, opts) end), - get_remote_state: zero_or_one_arg(fn _args -> repo_state(project_id, opts) end), - fetch: zero_or_one_arg(fn _args -> repo_fetch(project_id, opts) end), - pull: zero_or_one_arg(fn _args -> repo_pull(project_id, opts) end), - push: zero_or_one_arg(fn _args -> repo_push(project_id, opts) end), - commit_all: one_arg(fn message -> repo_commit_all(project_id, message, opts) end) - }, - publish: %{ - upload_site: one_arg(fn credentials -> upload_site(project_id, credentials, opts) end) - }, - chat: %{ - detect_post_language: - two_arg(fn title, content -> detect_post_language(title, content, opts) end), - analyze_post: one_arg(fn post_id -> analyze_post(post_id, opts) end), - translate_post: - two_arg(fn post_id, language -> translate_post(post_id, language, opts) end), - analyze_media_image: one_arg(fn media_id -> analyze_media_image(media_id, opts) end), - detect_media_language: - three_arg(fn title, alt, caption -> - detect_media_language(title, alt, caption, opts) - end), - translate_media_metadata: - two_arg(fn media_id, language -> translate_media_metadata(media_id, language, opts) end) - }, - embeddings: %{ - get_progress: zero_or_one_arg(fn _args -> embedding_progress(project_id) end), - find_similar: two_arg(fn post_id, limit -> find_similar(post_id, limit) end), - compute_similarities: - two_arg(fn post_id, target_ids -> compute_similarities(post_id, target_ids) end), - suggest_tags: - two_arg(fn post_id, exclude_tags -> suggest_tags(post_id, exclude_tags) end), - find_duplicates: zero_or_one_arg(fn _args -> find_duplicates(project_id) end), - dismiss_pair: two_arg(fn post_id_a, post_id_b -> dismiss_pair(post_id_a, post_id_b) end), - index_unindexed_posts: zero_or_one_arg(fn _args -> index_unindexed_posts(project_id) end) - } + app: app_capabilities(project_id, opts), + projects: project_capabilities(project_id), + meta: meta_capabilities(project_id), + posts: post_capabilities(project_id), + media: media_capabilities(project_id), + scripts: script_capabilities(project_id), + templates: template_capabilities(project_id), + tags: tag_capabilities(project_id), + tasks: task_capabilities(), + sync: sync_capabilities(project_id, opts), + publish: publish_capabilities(project_id, opts), + chat: chat_capabilities(opts), + embeddings: embedding_capabilities(project_id) + } + end + + defp app_capabilities(project_id, opts) do + %{ + copy_to_clipboard: one_arg(fn text -> copy_to_clipboard(text, opts) end), + get_data_paths: zero_or_one_arg(fn _args -> data_paths(project_id) end), + get_blogmark_bookmarklet: zero_or_one_arg(fn _args -> blogmark_bookmarklet() end), + get_system_language: zero_or_one_arg(fn _args -> I18n.current_ui_locale() end), + get_default_project_path: zero_or_one_arg(fn _args -> project_path(project_id) end), + get_title_bar_metrics: zero_or_one_arg(fn _args -> title_bar_metrics(opts) end), + notify_renderer_ready: zero_or_one_arg(fn _args -> notify_renderer_ready(opts) end), + open_folder: one_arg(fn folder_path -> open_folder(folder_path, opts) end), + read_project_metadata: one_arg(fn folder_path -> read_project_metadata(folder_path) end), + select_folder: one_arg(fn title -> select_folder(title, opts) end), + set_preview_post_target: one_arg(fn post_id -> set_preview_post_target(post_id) end), + show_item_in_folder: one_arg(fn item_path -> show_item_in_folder(item_path, opts) end), + trigger_menu_action: one_arg(fn action -> trigger_menu_action(action, opts) end) + } + end + + defp project_capabilities(project_id) do + %{ + create: zero_or_one_arg(fn attrs -> create_project(attrs) end), + delete: one_arg(fn project_id_to_delete -> delete_project(project_id_to_delete) end), + delete_with_data: + one_arg(fn project_id_to_delete -> delete_project_with_data(project_id_to_delete) end), + get: one_arg(fn project_id_to_load -> load_project(project_id_to_load) end), + get_all: zero_or_one_arg(fn _args -> list_projects() end), + get_active: zero_or_one_arg(fn _args -> load_project(project_id) end), + set_active: + one_arg(fn project_id_to_activate -> set_active_project(project_id_to_activate) end), + update: + two_arg(fn project_id_to_update, attrs -> + update_project(project_id_to_update, attrs) + end) + } + end + + defp meta_capabilities(project_id) do + %{ + get_project_metadata: zero_or_one_arg(fn _args -> load_metadata(project_id) end), + update_project_metadata: + one_arg(fn attrs -> update_project_metadata(project_id, attrs) end), + add_category: one_arg(fn name -> add_category(project_id, name) end), + remove_category: one_arg(fn name -> remove_category(project_id, name) end), + add_tag: one_arg(fn name -> add_meta_tag(project_id, name) end), + get_categories: zero_or_one_arg(fn _args -> metadata_categories(project_id) end), + set_project_metadata: one_arg(fn attrs -> update_project_metadata(project_id, attrs) end), + get_publishing_preferences: + zero_or_one_arg(fn _args -> publishing_preferences(project_id) end), + get_tags: zero_or_one_arg(fn _args -> metadata_tags(project_id) end), + remove_tag: one_arg(fn name -> remove_meta_tag(project_id, name) end), + set_publishing_preferences: + one_arg(fn prefs -> set_publishing_preferences(project_id, prefs) end), + clear_publishing_preferences: + zero_or_one_arg(fn _args -> clear_publishing_preferences(project_id) end), + sync_on_startup: zero_or_one_arg(fn _args -> sync_meta_on_startup(project_id) end) + } + end + + defp post_capabilities(project_id) do + %{ + create: one_arg(fn attrs -> create_post(project_id, attrs) end), + discard: one_arg(fn post_id -> discard_post(project_id, post_id) end), + filter: one_arg(fn filters -> filter_posts(project_id, filters) end), + generate_unique_slug: + two_arg(fn title, exclude_post_id -> + generate_unique_post_slug(project_id, title, exclude_post_id) + end), + get_by_status: one_arg(fn status -> posts_by_status(project_id, status) end), + get_by_year_month: zero_or_one_arg(fn _args -> post_counts_by_year_month(project_id) end), + get_dashboard_stats: zero_or_one_arg(fn _args -> post_dashboard_stats(project_id) end), + get_linked_by: + one_arg(fn post_id -> linked_posts_for(project_id, post_id, :incoming) end), + get_links_to: one_arg(fn post_id -> linked_posts_for(project_id, post_id, :outgoing) end), + get_preview_url: + two_arg(fn post_id, options -> preview_url(project_id, post_id, options) end), + update: two_arg(fn post_id, attrs -> update_post(project_id, post_id, attrs) end), + delete: one_arg(fn post_id -> delete_post(project_id, post_id) end), + get: one_arg(fn post_id -> load_post(project_id, post_id) end), + get_all: zero_or_one_arg(fn _args -> list_posts(project_id) end), + get_by_slug: one_arg(fn slug -> load_post_by_slug(project_id, slug) end), + get_categories: zero_or_one_arg(fn _args -> post_categories(project_id) end), + get_categories_with_counts: + zero_or_one_arg(fn _args -> post_categories_with_counts(project_id) end), + get_tags: zero_or_one_arg(fn _args -> post_tags(project_id) end), + get_tags_with_counts: zero_or_one_arg(fn _args -> post_tags_with_counts(project_id) end), + get_translation: + two_arg(fn post_id, language -> + load_post_translation(project_id, post_id, language) + end), + get_translations: one_arg(fn post_id -> list_post_translations(project_id, post_id) end), + has_published_version: + one_arg(fn post_id -> has_published_post_version(project_id, post_id) end), + is_slug_available: + two_arg(fn slug, exclude_post_id -> + post_slug_available?(project_id, slug, exclude_post_id) + end), + publish: one_arg(fn post_id -> publish_post(project_id, post_id) end), + publish_translation: + two_arg(fn post_id, language -> + publish_post_translation(project_id, post_id, language) + end), + rebuild_from_files: zero_or_one_arg(fn _args -> rebuild_posts_from_files(project_id) end), + rebuild_links: zero_or_one_arg(fn _args -> rebuild_post_links(project_id) end), + reindex_text: zero_or_one_arg(fn _args -> reindex_project_search(project_id) end), + search: one_arg(fn query -> search_posts(project_id, query) end) + } + end + + defp media_capabilities(project_id) do + %{ + delete_translation: + two_arg(fn media_id, language -> + delete_media_translation(project_id, media_id, language) + end), + filter: one_arg(fn filters -> filter_media(project_id, filters) end), + import: one_arg(fn attrs -> import_media(project_id, attrs) end), + get_by_year_month: + zero_or_one_arg(fn _args -> media_counts_by_year_month(project_id) end), + get_file_path: one_arg(fn media_id -> media_file_path(project_id, media_id) end), + update: two_arg(fn media_id, attrs -> update_media(project_id, media_id, attrs) end), + delete: one_arg(fn media_id -> delete_media(project_id, media_id) end), + get: one_arg(fn media_id -> load_media(project_id, media_id) end), + get_all: zero_or_one_arg(fn _args -> list_media(project_id) end), + get_tags: zero_or_one_arg(fn _args -> media_tags(project_id) end), + get_tags_with_counts: zero_or_one_arg(fn _args -> media_tags_with_counts(project_id) end), + get_thumbnail: + two_arg(fn media_id, size -> media_thumbnail(project_id, media_id, size) end), + get_translation: + two_arg(fn media_id, language -> + load_media_translation(project_id, media_id, language) + end), + get_translations: + one_arg(fn media_id -> list_media_translations(project_id, media_id) end), + get_url: one_arg(fn media_id -> media_url(project_id, media_id) end), + rebuild_from_files: zero_or_one_arg(fn _args -> rebuild_media_from_files(project_id) end), + regenerate_missing_thumbnails: + zero_or_one_arg(fn _args -> regenerate_missing_thumbnails(project_id) end), + regenerate_thumbnails: + one_arg(fn media_id -> regenerate_media_thumbnails(project_id, media_id) end), + reindex_text: zero_or_one_arg(fn _args -> reindex_project_search(project_id) end), + replace_file: + two_arg(fn media_id, source_path -> + replace_media_file(project_id, media_id, source_path) + end), + search: one_arg(fn query -> search_media(project_id, query) end), + upsert_translation: + three_arg(fn media_id, language, attrs -> + upsert_media_translation(project_id, media_id, language, attrs) + end) + } + end + + defp script_capabilities(project_id) do + %{ + create: one_arg(fn attrs -> create_script(project_id, attrs) end), + update: two_arg(fn script_id, attrs -> update_script(project_id, script_id, attrs) end), + delete: one_arg(fn script_id -> delete_script(project_id, script_id) end), + get: one_arg(fn script_id -> load_script(project_id, script_id) end), + get_all: zero_or_one_arg(fn _args -> list_scripts(project_id) end), + publish: one_arg(fn script_id -> publish_script(project_id, script_id) end), + rebuild_from_files: + zero_or_one_arg(fn _args -> rebuild_scripts_from_files(project_id) end) + } + end + + defp template_capabilities(project_id) do + %{ + create: one_arg(fn attrs -> create_template(project_id, attrs) end), + update: + two_arg(fn template_id, attrs -> update_template(project_id, template_id, attrs) end), + delete: one_arg(fn template_id -> delete_template(project_id, template_id) end), + get: one_arg(fn template_id -> load_template(project_id, template_id) end), + get_all: zero_or_one_arg(fn _args -> list_templates(project_id) end), + publish: one_arg(fn template_id -> publish_template(project_id, template_id) end), + get_enabled_by_kind: one_arg(fn kind -> list_enabled_templates(project_id, kind) end), + rebuild_from_files: + zero_or_one_arg(fn _args -> rebuild_templates_from_files(project_id) end), + validate: one_arg(fn source -> validate_template_source(source) end) + } + end + + defp tag_capabilities(project_id) do + %{ + create: one_arg(fn attrs -> create_tag(project_id, attrs) end), + update: two_arg(fn tag_id, attrs -> update_tag(project_id, tag_id, attrs) end), + delete: one_arg(fn tag_id -> delete_tag(project_id, tag_id) end), + get: one_arg(fn tag_id -> load_tag(project_id, tag_id) end), + get_all: zero_or_one_arg(fn _args -> list_tags(project_id) end), + get_by_name: one_arg(fn tag_name -> load_tag_by_name(project_id, tag_name) end), + get_posts_with_tag: one_arg(fn tag_id -> tag_post_ids(project_id, tag_id) end), + get_with_counts: zero_or_one_arg(fn _args -> tags_with_counts(project_id) end), + merge: + two_arg(fn source_tag_ids, target_tag_id -> + merge_tags(project_id, source_tag_ids, target_tag_id) + end), + rename: two_arg(fn tag_id, new_name -> rename_tag(project_id, tag_id, new_name) end), + sync_from_posts: zero_or_one_arg(fn _args -> sync_tags_from_posts(project_id) end) + } + end + + defp task_capabilities do + %{ + get: one_arg(fn task_id -> load_task(task_id) end), + status_snapshot: zero_or_one_arg(fn _args -> sanitize(Tasks.status_snapshot()) end), + cancel: one_arg(fn task_id -> cancel_task(task_id) end), + get_all: zero_or_one_arg(fn _args -> list_all_tasks() end), + get_running: zero_or_one_arg(fn _args -> list_running_tasks() end), + clear_completed: zero_or_one_arg(fn _args -> clear_completed_tasks() end) + } + end + + defp sync_capabilities(project_id, opts) do + %{ + check_availability: zero_or_one_arg(fn _args -> sync_available?() end), + get_repo_state: zero_or_one_arg(fn _args -> repo_state(project_id, opts) end), + get_status: zero_or_one_arg(fn _args -> repo_status(project_id, opts) end), + get_history: zero_or_one_arg(fn _args -> repo_history(project_id, opts) end), + get_remote_state: zero_or_one_arg(fn _args -> repo_state(project_id, opts) end), + fetch: zero_or_one_arg(fn _args -> repo_fetch(project_id, opts) end), + pull: zero_or_one_arg(fn _args -> repo_pull(project_id, opts) end), + push: zero_or_one_arg(fn _args -> repo_push(project_id, opts) end), + commit_all: one_arg(fn message -> repo_commit_all(project_id, message, opts) end) + } + end + + defp publish_capabilities(project_id, opts) do + %{ + upload_site: one_arg(fn credentials -> upload_site(project_id, credentials, opts) end) + } + end + + defp chat_capabilities(opts) do + %{ + detect_post_language: + two_arg(fn title, content -> detect_post_language(title, content, opts) end), + analyze_post: one_arg(fn post_id -> analyze_post(post_id, opts) end), + translate_post: + two_arg(fn post_id, language -> translate_post(post_id, language, opts) end), + analyze_media_image: one_arg(fn media_id -> analyze_media_image(media_id, opts) end), + detect_media_language: + three_arg(fn title, alt, caption -> + detect_media_language(title, alt, caption, opts) + end), + translate_media_metadata: + two_arg(fn media_id, language -> translate_media_metadata(media_id, language, opts) end) + } + end + + defp embedding_capabilities(project_id) do + %{ + get_progress: zero_or_one_arg(fn _args -> embedding_progress(project_id) end), + find_similar: two_arg(fn post_id, limit -> find_similar(post_id, limit) end), + compute_similarities: + two_arg(fn post_id, target_ids -> compute_similarities(post_id, target_ids) end), + suggest_tags: + two_arg(fn post_id, exclude_tags -> suggest_tags(post_id, exclude_tags) end), + find_duplicates: zero_or_one_arg(fn _args -> find_duplicates(project_id) end), + dismiss_pair: two_arg(fn post_id_a, post_id_b -> dismiss_pair(post_id_a, post_id_b) end), + index_unindexed_posts: zero_or_one_arg(fn _args -> index_unindexed_posts(project_id) end) } end end diff --git a/lib/bds/templates.ex b/lib/bds/templates.ex index 2df477e..a15e0a1 100644 --- a/lib/bds/templates.ex +++ b/lib/bds/templates.ex @@ -95,61 +95,15 @@ defmodule BDS.Templates do end defp do_update_template(template, attrs) do - next_slug = - if has_attr?(attrs, :slug) do - unique_slug( - template.project_id, - Slug.slugify(attr(attrs, :slug)), - "template", - template.id - ) - else - template.slug - end - - content_changed? = - has_attr?(attrs, :content) and - attr(attrs, :content) != effective_template_content(template) - - slug_changed? = next_slug != template.slug now = Persistence.now_ms() - - next_status = - if(template.status == :published and content_changed?, - do: :draft, - else: template.status - ) - - next_file_path = next_template_file_path(template, next_slug) - - updates = - %{} - |> maybe_put(:title, attr(attrs, :title)) - |> maybe_put(:kind, attr(attrs, :kind)) - |> maybe_put(:enabled, attr(attrs, :enabled)) - |> maybe_put(:content, attr(attrs, :content)) - |> Map.put(:file_path, next_file_path) - |> Map.put(:slug, next_slug) - |> Map.put(:version, template.version + 1) - |> Map.put(:updated_at, now) - |> Map.put(:status, next_status) + next_slug = resolve_next_slug(template, attrs) + slug_changed? = next_slug != template.slug + content_changed? = content_changed?(template, attrs) + next_status = resolve_next_status(template, content_changed?) + updates = build_update_attrs(template, attrs, next_slug, next_status, now) with {:ok, {updated_template, affected_posts}} <- - Repo.transaction(fn -> - updated_template = - template - |> Template.changeset(updates) - |> Repo.update!() - - affected_posts = - if slug_changed? do - cascade_template_slug_change(template, updated_template, now) - else - [] - end - - {updated_template, affected_posts} - end), + commit_update_transaction(template, updates, slug_changed?, now), :ok <- sync_template_update_side_effects( template, @@ -161,6 +115,58 @@ defmodule BDS.Templates do end end + defp resolve_next_slug(template, attrs) do + if has_attr?(attrs, :slug) do + unique_slug( + template.project_id, + Slug.slugify(attr(attrs, :slug)), + "template", + template.id + ) + else + template.slug + end + end + + defp content_changed?(template, attrs) do + has_attr?(attrs, :content) and + attr(attrs, :content) != effective_template_content(template) + end + + defp resolve_next_status(%Template{status: :published}, true = _content_changed?), do: :draft + defp resolve_next_status(%Template{status: status}, _content_changed?), do: status + + defp build_update_attrs(template, attrs, next_slug, next_status, now) do + %{} + |> maybe_put(:title, attr(attrs, :title)) + |> maybe_put(:kind, attr(attrs, :kind)) + |> maybe_put(:enabled, attr(attrs, :enabled)) + |> maybe_put(:content, attr(attrs, :content)) + |> Map.put(:file_path, next_template_file_path(template, next_slug)) + |> Map.put(:slug, next_slug) + |> Map.put(:version, template.version + 1) + |> Map.put(:updated_at, now) + |> Map.put(:status, next_status) + end + + defp commit_update_transaction(template, updates, slug_changed?, now) do + Repo.transaction(fn -> + updated_template = + template + |> Template.changeset(updates) + |> Repo.update!() + + affected_posts = + if slug_changed? do + cascade_template_slug_change(template, updated_template, now) + else + [] + end + + {updated_template, affected_posts} + end) + end + @spec rebuild_templates_from_files(String.t(), keyword()) :: {:ok, [Template.t()]} def rebuild_templates_from_files(project_id, opts \\ []) do project = Projects.get_project!(project_id) diff --git a/test/bds/csm023_srp_violations_test.exs b/test/bds/csm023_srp_violations_test.exs new file mode 100644 index 0000000..cd9da68 --- /dev/null +++ b/test/bds/csm023_srp_violations_test.exs @@ -0,0 +1,85 @@ +defmodule BDS.CSM023SrpViolationsTest do + use ExUnit.Case, async: true + + describe "Templates.do_update_template/2 decomposition" do + test "update_template pipeline is decomposed into focused private functions" do + source = File.read!("lib/bds/templates.ex") + + assert source =~ "defp resolve_next_slug(" + assert source =~ "defp content_changed?(" + assert source =~ "defp resolve_next_status(" + assert source =~ "defp build_update_attrs(" + assert source =~ "defp commit_update_transaction(" + + refute source =~ "defp do_update_template(template, attrs) do\n next_slug =", + "do_update_template should delegate to focused helpers, not inline all logic" + end + + test "do_update_template delegates to helpers instead of inlining" do + source = File.read!("lib/bds/templates.ex") + + [do_update_body] = + Regex.scan( + ~r/defp do_update_template\(template, attrs\) do\n(.*?)(?=\n defp )/s, + source, + capture: :all_but_first + ) + |> Enum.map(&hd/1) + + assert do_update_body =~ "resolve_next_slug(template, attrs)" + assert do_update_body =~ "content_changed?(template, attrs)" + assert do_update_body =~ "resolve_next_status(template, content_changed?)" + assert do_update_body =~ "build_update_attrs(template, attrs, next_slug, next_status, now)" + assert do_update_body =~ "commit_update_transaction(template, updates, slug_changed?, now)" + end + end + + describe "Capabilities.for_project/2 decomposition" do + test "for_project delegates to domain-specific builder functions" do + source = File.read!("lib/bds/scripting/capabilities.ex") + + assert source =~ "defp app_capabilities(" + assert source =~ "defp project_capabilities(" + assert source =~ "defp meta_capabilities(" + assert source =~ "defp post_capabilities(" + assert source =~ "defp media_capabilities(" + assert source =~ "defp script_capabilities(" + assert source =~ "defp template_capabilities(" + assert source =~ "defp tag_capabilities(" + assert source =~ "defp task_capabilities" + assert source =~ "defp sync_capabilities(" + assert source =~ "defp publish_capabilities(" + assert source =~ "defp chat_capabilities(" + assert source =~ "defp embedding_capabilities(" + end + + test "for_project body is a concise map of builder calls" do + source = File.read!("lib/bds/scripting/capabilities.ex") + for_project_body = extract_for_project_body(source) + + line_count = for_project_body |> String.split("\n") |> length() + + assert line_count <= 20, + "for_project body should be a concise dispatch map, got #{line_count} lines" + end + + test "no domain-specific capability entries remain in for_project body" do + source = File.read!("lib/bds/scripting/capabilities.ex") + for_project_body = extract_for_project_body(source) + + refute for_project_body =~ "one_arg(", + "for_project body should not contain one_arg calls directly" + + refute for_project_body =~ "zero_or_one_arg(", + "for_project body should not contain zero_or_one_arg calls directly" + end + + defp extract_for_project_body(source) do + lines = String.split(source, "\n") + start_idx = Enum.find_index(lines, &String.contains?(&1, "def for_project(")) + remaining = Enum.drop(lines, start_idx + 1) + end_idx = Enum.find_index(remaining, &(&1 == " end")) + remaining |> Enum.take(end_idx) |> Enum.join("\n") + end + end +end